Compose Ornithopter with unused Hajr movement
Replace one unused movement entitlement by the selected card mode and retain independent Hajr movement after a completed two-group flight. Preserve distinct cohorts and later legal reuse. Adopt supplied Advanced immediate-after-movement Hajr play timing with explicit provisional online/card-group interpretation; Basic timing remains. Validation: frozen11-file132/132, types/lint/build and12 original-action/rendered-table programmes; four minimal policies retain both continuations. Actual cards are acquired by original auctions and source groups use funded shipments. Multiple Hajr cards, advisors, wider combinations, final assurance and deployed acceptance remain open. No saved-game, reset, restart or deployment operation.
This commit is contained in:
1 parent
6ccfaa1b4d
commit
72e77d7f4d
16 files changed
+522
-31
No files matched your search
@@ -35,6 +35,10 @@ advisors, Polar Sink and native Ecaz coexistence retain their exceptions.
|
|||||||
Basic and an already recorded current Storm keep their existing protocol.
|
Basic and an already recorded current Storm keep their existing protocol.
|
||||||
See [Advanced source boundaries](docs/BASE_ADVANCED_READINESS_20260907.md#authorized-advanced-source-follow-up--1-october-2026)
|
See [Advanced source boundaries](docs/BASE_ADVANCED_READINESS_20260907.md#authorized-advanced-source-follow-up--1-october-2026)
|
||||||
and [allied separation](docs/ALLIED_TRANSIT_RULES.md).
|
and [allied separation](docs/ALLIED_TRANSIT_RULES.md).
|
||||||
|
Advanced Hajr is played immediately after a completed Force Movement and its
|
||||||
|
required arrival decisions. Basic keeps its broader own-turn timing. The
|
||||||
|
[Ornithopter combination](docs/RICHESE_ORNITHOPTER_ENGINE_AUDIT.md#hajr-and-unused-movement-entitlements--18-october-2026)
|
||||||
|
uses one unused movement entitlement and retains Hajr's separate move.
|
||||||
|
|
||||||
Before starting, open **Configure** on an AI seat to change its difficulty,
|
Before starting, open **Configure** on an AI seat to change its difficulty,
|
||||||
faction or player circle without replacing it. Changes save immediately and
|
faction or player circle without replacing it. Changes save immediately and
|
||||||
|
|||||||
@@ -5003,6 +5003,8 @@ export function GameTable({
|
|||||||
? `${(me.movesAllowed ?? 1) - (me.moved ?? 0)} movement remaining.`
|
? `${(me.movesAllowed ?? 1) - (me.moved ?? 0)} movement remaining.`
|
||||||
: 'Movement complete. Finish your turn when your shipment is also settled.'}
|
: 'Movement complete. Finish your turn when your shipment is also settled.'}
|
||||||
</p>
|
</p>
|
||||||
|
{g.ornithopter && <p className="fine">Provisional card composition: Ornithopter replaces one unused movement. Its two groups must be different; Hajr adds one separate normal move and may move those forces again.</p>}
|
||||||
|
{g.advanced && me.hand?.some(card => card.effect === 'hajr') && <p className="fine">Play Hajr immediately after a completed Force Movement and its required arrival decisions, before your next voluntary action.</p>}
|
||||||
{transportPreview && (
|
{transportPreview && (
|
||||||
<>
|
<>
|
||||||
<ShipmentQuote
|
<ShipmentQuote
|
||||||
|
|||||||
@@ -16,6 +16,23 @@ open mode gates.
|
|||||||
|
|
||||||
|
|
||||||
## Current checkpoint and work
|
## Current checkpoint and work
|
||||||
|
**Ornithopter / Hajr — 18 October. Prototyped / Partial.** New card uses replace
|
||||||
|
one unused movement by the selected mode and retain the independent Hajr move.
|
||||||
|
Prior ordinary moves stay spent; groups are distinct within the flight and may
|
||||||
|
be reused by Hajr afterward. Advanced now adopts supplied immediate-after-move
|
||||||
|
play timing with an explicit online/card-group interpretation; Basic timing stays.
|
||||||
|
|
||||||
|
One disjoint fixture lane, owner integration and bounded source review pass
|
||||||
|
frozen **11 files132/132**, types/lint/build and12 original-action/rendered-table
|
||||||
|
programmes. Real cache/normal purchases and paid shipment use38 Basic/45 Advanced
|
||||||
|
source actions, native CHOAM/Baliset65. Early ends grant no abandoned group;
|
||||||
|
four minimal policies retain both legal movement continuations. Two historical
|
||||||
|
Homeworld/Semuta guard cases now exercise the new entitlement and source timing.
|
||||||
|
Multiple Hajr cards, advisor use, Guild Cunning/card and wider combinations remain
|
||||||
|
open. Framework aliases/CSS stubs mean no visual/browser/deployed acceptance.
|
||||||
|
No saved room/database mutation, reset, restart or deployment.
|
||||||
|
[Source cutover, provisional procedure and proof](RICHESE_ORNITHOPTER_ENGINE_AUDIT.md#hajr-and-unused-movement-entitlements--18-october-2026).
|
||||||
|
|
||||||
**Stone Burner physical allocation — 18 October. Prototyped / Partial.**
|
**Stone Burner physical allocation — 18 October. Prototyped / Partial.**
|
||||||
New original Basic/Advanced Stone and copied Mirror battles now finish
|
New original Basic/Advanced Stone and copied Mirror battles now finish
|
||||||
outcome-changing mixed-force comparisons. Only ambiguous sides privately commit
|
outcome-changing mixed-force comparisons. Only ambiguous sides privately commit
|
||||||
|
|||||||
@@ -1,5 +1,23 @@
|
|||||||
# Dune implementation status
|
# Dune implementation status
|
||||||
|
|
||||||
|
## 18 October 2026 — Ornithopter and unused Hajr movement
|
||||||
|
|
||||||
|
One unused normal/Hajr entitlement now supports either card mode without losing
|
||||||
|
the independent Hajr move after a completed two-group flight. Prior ordinary
|
||||||
|
movement is not reclassified; per-flight cohorts remain distinct and later Hajr
|
||||||
|
reuse is legal. Advanced adopts supplied immediate-after-movement play timing;
|
||||||
|
online window/card-group composition is explicitly provisional.
|
||||||
|
|
||||||
|
One exclusive fixture lane plus owner integration/bounded source review pass
|
||||||
|
frozen11-file132/132, types/lint/build and12 original-action/rendered-table
|
||||||
|
programmes. Actual purchases/paid shipment, typed movement, native Baliset pause,
|
||||||
|
owned window closure/reopening, early end and four legal policies have bounded
|
||||||
|
evidence. Two old guard assertions now cover new entitlements and source timing.
|
||||||
|
[Canonical source, exact counts and remaining scope](RICHESE_ORNITHOPTER_ENGINE_AUDIT.md#hajr-and-unused-movement-entitlements--18-october-2026).
|
||||||
|
Multiple Hajr cards, advisor/card and wider module combinations remain unfinished;
|
||||||
|
no comprehensive assurance, full-mode or deployed claim. No saved-room/reset/
|
||||||
|
restart/deployment operation.
|
||||||
|
|
||||||
## 18 October 2026 — Outcome-changing Stone physical totals
|
## 18 October 2026 — Outcome-changing Stone physical totals
|
||||||
|
|
||||||
New original Basic/Advanced Stone and copied Mirror now request sealed physical
|
New original Basic/Advanced Stone and copied Mirror now request sealed physical
|
||||||
|
|||||||
@@ -2,6 +2,73 @@
|
|||||||
|
|
||||||
Audit: 2026-09-06. Document-only source and engine review. The canonical component is `richese-ornithopter`; this is distinct from ordinary Arrakeen/Carthag ornithopter access and the later Ecaz discovery token. No runtime effect or complete expansion compliance is claimed here.
|
Audit: 2026-09-06. Document-only source and engine review. The canonical component is `richese-ornithopter`; this is distinct from ordinary Arrakeen/Carthag ornithopter access and the later Ecaz discovery token. No runtime effect or complete expansion compliance is claimed here.
|
||||||
|
|
||||||
|
## Hajr and unused movement entitlements — 18 October 2026
|
||||||
|
|
||||||
|
**Prototyped / Partial.** The photographed Ornithopter face supplies its two
|
||||||
|
alternatives but no Hajr combination procedure. The authorized supplied PDF,
|
||||||
|
physical p10/logical p8, checks ordinary ornithopter access for each Force
|
||||||
|
Movement, including Hajr. Its physical p39/logical p37 Hajr errata separately
|
||||||
|
requires playing immediately after a Force Movement and allows reusing a moved
|
||||||
|
group or choosing another group. This is the supplied unofficial compilation,
|
||||||
|
not a new GF9 combination ruling. Bounded official-source follow-up found no
|
||||||
|
card-specific resolution; the publisher PDF endpoints returned403.
|
||||||
|
|
||||||
|
Under the standing choose-recommended instruction, provisionally replace one
|
||||||
|
**unused** ordinary/Hajr movement entitlement with either a range-three move or
|
||||||
|
two distinct normal-range groups. One Hajr adds one independent normal move:
|
||||||
|
one two-group flight plus that Hajr permits three completed group movements.
|
||||||
|
An earlier ordinary movement stays spent; it cannot be retrospectively counted
|
||||||
|
as the card's first group. Its counters may join a later newly declared flight.
|
||||||
|
The two groups within that flight must differ, while a later Hajr move may reuse
|
||||||
|
the first card group. Range-three stays capped at three and normal range is
|
||||||
|
recomputed for every other group. No extra shipment or spice fee is created.
|
||||||
|
|
||||||
|
Advanced adopts the supplied immediate-after-play timing, correcting the older
|
||||||
|
broader availability. A completed card group provisionally counts as a Force
|
||||||
|
Movement for that opportunity. Mandatory arrival/disposal decisions finish first;
|
||||||
|
other seats' actions and polling do not close it. The owner's next voluntary
|
||||||
|
non-Hajr game action closes it, and a later actual movement opens a new window.
|
||||||
|
Basic preserves its original broader own-turn timing. Existing separate Nexus
|
||||||
|
Guild Cunning timing remains its own adapter; physical-card composition there
|
||||||
|
is still guarded. The supplied passage neither permits nor forbids stacking
|
||||||
|
multiple physical Hajr cards; the existing single-Hajr limit is an unfinished
|
||||||
|
implementation boundary, not a printed once-per-turn rule established here.
|
||||||
|
|
||||||
|
Shared movement accounting retains a credit only after the second actual card
|
||||||
|
group completes, so clearing the played-card frame cannot consume unused Hajr.
|
||||||
|
Early ending abandons the second group and ends the turn without that credit.
|
||||||
|
Original event/cohort validation and reaction continuations remain. The private
|
||||||
|
view projects current Hajr availability separately from the total move count;
|
||||||
|
human controls and all four minimal bot policies consume those same facts.
|
||||||
|
New movement fields are additive and cleared with the next movement-phase
|
||||||
|
counters. Completed legacy flights without a credit are not reconstructed.
|
||||||
|
|
||||||
|
One fixture lane plus owner integration and bounded source/implementation review
|
||||||
|
pass frozen **11 files132/132**, types/lint/build and **12** original-action/
|
||||||
|
actual-GameTable rendering programmes. Genuine cache Ornithopter1, ordinary
|
||||||
|
Hajr1 and funded desert shipment reach the source in38 Basic/45 Advanced actions.
|
||||||
|
Before/during/after Basic and after-group/after-flight Advanced use five
|
||||||
|
continuations; prior-normal then fresh flight also five, range-three plus Hajr
|
||||||
|
four. Both modes keep the distinct-group rule and legal later reuse. Early ends
|
||||||
|
earn no abandoned movement. The native Advanced CHOAM/Baliset pause uses65
|
||||||
|
source actions; declining it opens Hajr, an original funded bribe closes the
|
||||||
|
window, and the second typed group reopens it. Four policies retain legal second
|
||||||
|
groups and earned extra moves (eight proposals), without tuning.
|
||||||
|
|
||||||
|
Two older Homeworld/Semuta guard assertions were updated to the new entitlement
|
||||||
|
and Advanced timing while retaining the fixed maximum-three rejection. Related
|
||||||
|
original disposal and Guild Cunning regressions pass; this does not certify all
|
||||||
|
World/Semuta combinations. Framework aliases/CSS stubs mean no visual/browser/
|
||||||
|
deployed acceptance. Advisor/card use, multiple Hajr cards, wider module
|
||||||
|
combinations, comprehensive assurance and complete modes remain open. No saved
|
||||||
|
room/database mutation, reset, restart or deployment.
|
||||||
|
|
||||||
|
Proof base `6ccfaa1b4d5198f7e1654d5f4341bd6fc7a5daaa`, frozen tree
|
||||||
|
`29c081daee908f14d502e1a1adc5d871216c031460e830806e7699720b94dee0`;
|
||||||
|
private reports/HTML are under
|
||||||
|
`/home/quintine/.local/share/dune/checkpoints/20261018-ornithopter-hajr/`.
|
||||||
|
The older Hajr guards below are dated implementation history.
|
||||||
|
|
||||||
## Provisional Kulon coexistence — 9 October 2026
|
## Provisional Kulon coexistence — 9 October 2026
|
||||||
|
|
||||||
Both modes now remain usable after Kulon. The one-group card move retains
|
Both modes now remain usable after Kulon. The one-group card move retains
|
||||||
|
|||||||
@@ -873,6 +873,7 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t
|
|||||||
|
|
||||||
| Boundary | Still unresolved | Existing record |
|
| Boundary | Still unresolved | Existing record |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| Ornithopter / Hajr | Standing choose-recommended provisionally replaces one unused ordinary/Hajr entitlement by range-three or two distinct normal groups, with no retroactive credit. A later Hajr may reuse card-moved forces. Advanced adopts supplied physical39/logical37 immediately-after-play timing; treating a completed card group as Force Movement and preserving its window through required arrivals are explicit composition choices. Basic retains broader face timing. Multiple physical Hajr stacking remains an existing unsupported boundary: the supplied paragraph establishes neither permission nor a once-per-turn prohibition. | [Source cutover and exact procedure](RICHESE_ORNITHOPTER_ENGINE_AUDIT.md#hajr-and-unused-movement-entitlements--18-october-2026) |
|
||||||
| Reinforcements cost | The earlier all-outcome versus sole-Traitor-winner question remains unanswered. The bounded prototype provisionally charges on every revealed outcome, chooses normal reserves first and counts the transfer as battle losses. Same-plan Harass pays the original quote before returns; future returns cannot fund or change it. Native Advanced Occupy Stone/Mirror composition keeps the fee but ignores +2 normal score in physical-token comparison. These are product interpretations, not a new publisher ruling or user answer. Other profile/module gates remain; eligibility never depends on concealed Traitors. | [Source review](ECAZ_TREACHERY_RULES.md#14-september-reinforcements-cost-review) · [Combined policy](REINFORCEMENTS_RUNTIME.md#combined-utility-plan--16-october-2026) · [Stone composition](REINFORCEMENTS_RUNTIME.md#native-stone-composition--16-october-2026) |
|
| Reinforcements cost | The earlier all-outcome versus sole-Traitor-winner question remains unanswered. The bounded prototype provisionally charges on every revealed outcome, chooses normal reserves first and counts the transfer as battle losses. Same-plan Harass pays the original quote before returns; future returns cannot fund or change it. Native Advanced Occupy Stone/Mirror composition keeps the fee but ignores +2 normal score in physical-token comparison. These are product interpretations, not a new publisher ruling or user answer. Other profile/module gates remain; eligibility never depends on concealed Traitors. | [Source review](ECAZ_TREACHERY_RULES.md#14-september-reinforcements-cost-review) · [Combined policy](REINFORCEMENTS_RUNTIME.md#combined-utility-plan--16-october-2026) · [Stone composition](REINFORCEMENTS_RUNTIME.md#native-stone-composition--16-october-2026) |
|
||||||
| Harass / Stone Burner | The earlier keep-gated versus compare-after-withdrawal question is unanswered. Standing choose-recommended provisionally compares counters left after successful withdrawal. Opposing Traitor cancels the return; original fixed Occupy commitment stays. Public preflight considers possible fixed Ecaz withdrawal without hidden custody. Clean Ix priority follows its separate explicitly provisional contract; new physical commitments do not remove other profile/module limits. This is implementation interpretation, not a new publisher ruling or user answer. | [Withdrawal](HARASS_WITHDRAW_RUNTIME.md#provisional-stone-burner-timing--16-october-2026) · [Clean Ix priority](MIRROR_WEAPON_ENGINE_AUDIT.md#clean-selected-ix-composition--16-october-2026) |
|
| Harass / Stone Burner | The earlier keep-gated versus compare-after-withdrawal question is unanswered. Standing choose-recommended provisionally compares counters left after successful withdrawal. Opposing Traitor cancels the return; original fixed Occupy commitment stays. Public preflight considers possible fixed Ecaz withdrawal without hidden custody. Clean Ix priority follows its separate explicitly provisional contract; new physical commitments do not remove other profile/module limits. This is implementation interpretation, not a new publisher ruling or user answer. | [Withdrawal](HARASS_WITHDRAW_RUNTIME.md#provisional-stone-burner-timing--16-october-2026) · [Clean Ix priority](MIRROR_WEAPON_ENGINE_AUDIT.md#clean-selected-ix-composition--16-october-2026) |
|
||||||
| Stone mixed-force allocation timing | The older timing question remains unanswered by a source. Under 14 October choose-recommended, new original outcome-ambiguous Stone/Mirror battles provisionally commit each ambiguous side's undialed physical total after revealed modes/actual withdrawal and decisive Traitor/explosion branches. Storm-order owners choose privately; every original required value stays sealed until all commit. The eventual winner retains all legal typed casualty choices with the count. Ordinary/invariant and unstamped legacy frames keep their existing path. Publisher physical-count text and supplied Advanced physical12/logical10 casualty timing provide the numerical rules, not this online commitment convention. | [Source, versioned procedure and bounded proof](STONE_BURNER_ENGINE_AUDIT.md#outcome-changing-physical-totals--18-october-2026) |
|
| Stone mixed-force allocation timing | The older timing question remains unanswered by a source. Under 14 October choose-recommended, new original outcome-ambiguous Stone/Mirror battles provisionally commit each ambiguous side's undialed physical total after revealed modes/actual withdrawal and decisive Traitor/explosion branches. Storm-order owners choose privately; every original required value stays sealed until all commit. The eventual winner retains all legal typed casualty choices with the count. Ordinary/invariant and unstamped legacy frames keep their existing path. Publisher physical-count text and supplied Advanced physical12/logical10 casualty timing provide the numerical rules, not this online commitment convention. | [Source, versioned procedure and bounded proof](STONE_BURNER_ENGINE_AUDIT.md#outcome-changing-physical-totals--18-october-2026) |
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ export type OrdinaryCardPlayer = {
|
|||||||
advisors?: Record<string, { lockedTurn?: number }>;
|
advisors?: Record<string, { lockedTurn?: number }>;
|
||||||
hand?: readonly Card[];
|
hand?: readonly Card[];
|
||||||
movesAllowed?: number;
|
movesAllowed?: number;
|
||||||
|
moved?: number;
|
||||||
|
hajrUsed?: boolean;
|
||||||
|
hajrAvailable?: boolean;
|
||||||
};
|
};
|
||||||
/** Only public state and the current viewer's own hand are inspected. */
|
/** Only public state and the current viewer's own hand are inspected. */
|
||||||
export type OrdinaryCardState = {
|
export type OrdinaryCardState = {
|
||||||
@@ -102,9 +105,12 @@ export function ordinaryCardAvailability(
|
|||||||
if (
|
if (
|
||||||
state.phase !== 5 ||
|
state.phase !== 5 ||
|
||||||
state.active !== state.me ||
|
state.active !== state.me ||
|
||||||
(state.nexusGuildCunning?.active ? !state.nexusGuildCunning.active.hajrAvailable : own.movesAllowed !== 1)
|
(state.nexusGuildCunning?.active ? !state.nexusGuildCunning.active.hajrAvailable :
|
||||||
|
own.hajrAvailable !== undefined ? !own.hajrAvailable :
|
||||||
|
own.hajrUsed === undefined ? own.movesAllowed !== 1 :
|
||||||
|
own.hajrUsed || (own.moved ?? 0) > (own.movesAllowed ?? 1))
|
||||||
)
|
)
|
||||||
return blocked('Use Hajr during your movement.');
|
return blocked('Use one Hajr during your movement; Advanced requires the immediate opportunity after a completed Force Movement.');
|
||||||
break;
|
break;
|
||||||
case 'harvester':
|
case 'harvester':
|
||||||
if (state.phase !== 1 || !harvesterAvailable(state.spiceWindow))
|
if (state.phase !== 1 || !harvesterAvailable(state.spiceWindow))
|
||||||
|
|||||||
+31
-10
@@ -400,6 +400,7 @@ import {
|
|||||||
} from './residual-poison';
|
} from './residual-poison';
|
||||||
import {
|
import {
|
||||||
createRemainingCohort,
|
createRemainingCohort,
|
||||||
|
ordinaryMovementAllowance,
|
||||||
validateCohortSelection,
|
validateCohortSelection,
|
||||||
type MovementCohort,
|
type MovementCohort,
|
||||||
type OrnithopterMode,
|
type OrnithopterMode,
|
||||||
@@ -681,6 +682,10 @@ export type Player = {
|
|||||||
ecazRevivalCohort?: EcazRevivalCohort;
|
ecazRevivalCohort?: EcazRevivalCohort;
|
||||||
shipped: boolean;
|
shipped: boolean;
|
||||||
moved: number;
|
moved: number;
|
||||||
|
/** Completed second groups replace one entitlement without consuming Hajr. */
|
||||||
|
ornithopterExtraMoves?: number;
|
||||||
|
/** Supplied Advanced Hajr timing: the owner's next voluntary action. */
|
||||||
|
hajrWindow?: {turn: number; move: number};
|
||||||
battleLosses: number;
|
battleLosses: number;
|
||||||
kwisatz?: { dead: boolean; usedAt?: string; revivalCycle?: number };
|
kwisatz?: { dead: boolean; usedAt?: string; revivalCycle?: number };
|
||||||
prediction?: { faction: FactionId; turn: number };
|
prediction?: { faction: FactionId; turn: number };
|
||||||
@@ -6085,6 +6090,8 @@ function ornithopterDiscardSignature(
|
|||||||
advisors: p.advisors,
|
advisors: p.advisors,
|
||||||
noField: p.noField,
|
noField: p.noField,
|
||||||
noFieldEvent: p.noFieldEvent,
|
noFieldEvent: p.noFieldEvent,
|
||||||
|
ornithopterExtraMoves: p.ornithopterExtraMoves,
|
||||||
|
hajrWindow: p.hajrWindow,
|
||||||
moved: p.moved,
|
moved: p.moved,
|
||||||
shipped: p.shipped,
|
shipped: p.shipped,
|
||||||
})),
|
})),
|
||||||
@@ -15568,9 +15575,11 @@ function movesAllowed(g: Game, p: Player) {
|
|||||||
const flight = g.ornithopter;
|
const flight = g.ornithopter;
|
||||||
return flight?.player === p.id && flight.turn === g.turn
|
return flight?.player === p.id && flight.turn === g.turn
|
||||||
? flight.startingMove + (flight.mode === 'twoGroups' ? 2 : 1)
|
? flight.startingMove + (flight.mode === 'twoGroups' ? 2 : 1)
|
||||||
: g.hajr.includes(p.id)
|
: ordinaryMovementAllowance(g.hajr.includes(p.id), p.ornithopterExtraMoves);
|
||||||
? 2
|
}
|
||||||
: 1;
|
function hajrAvailable(g: Game, p: Player) {
|
||||||
|
return !g.hajr.includes(p.id) && p.moved <= movesAllowed(g,p) &&
|
||||||
|
(!g.advanced || (p.hajrWindow?.turn === g.turn && p.hajrWindow.move === p.moved));
|
||||||
}
|
}
|
||||||
function emperorHomeworldMoveBlock(g: Game, p: Player): string | null {
|
function emperorHomeworldMoveBlock(g: Game, p: Player): string | null {
|
||||||
if (!g.homeworlds?.custody || !g.advanced || p.faction !== 'emperor')
|
if (!g.homeworlds?.custody || !g.advanced || p.faction !== 'emperor')
|
||||||
@@ -15637,8 +15646,8 @@ function ornithopterBlock(
|
|||||||
if (g.ornithopter)
|
if (g.ornithopter)
|
||||||
return 'Finish the already played Ornithopter movement first.';
|
return 'Finish the already played Ornithopter movement first.';
|
||||||
if (currentGuildCunning(g,p.id)) return 'Guild Cunning combined with the physical Ornithopter card awaits its timing ruling.';
|
if (currentGuildCunning(g,p.id)) return 'Guild Cunning combined with the physical Ornithopter card awaits its timing ruling.';
|
||||||
if (p.moved || g.hajr.includes(p.id))
|
if (p.moved >= movesAllowed(g,p))
|
||||||
return 'Ornithopter combined with prior moves or Hajr awaits its timing ruling.';
|
return 'Spend an unused ordinary or Hajr movement on Ornithopter.';
|
||||||
// Provisional composition: Kulon augments normal movement, not the
|
// Provisional composition: Kulon augments normal movement, not the
|
||||||
// Ornithopter card's explicit maximum of three territories.
|
// Ornithopter card's explicit maximum of three territories.
|
||||||
return null;
|
return null;
|
||||||
@@ -16252,6 +16261,7 @@ function completeMove(g: Game, move: MovementOrder) {
|
|||||||
finishShipmentPromises(g, p, null);
|
finishShipmentPromises(g, p, null);
|
||||||
p.shipped = true;
|
p.shipped = true;
|
||||||
p.moved++;
|
p.moved++;
|
||||||
|
p.hajrWindow = {turn:g.turn,move:p.moved};
|
||||||
if (move.discoveryFlight) {
|
if (move.discoveryFlight) {
|
||||||
g.discoveries = nexusRule(() => completeDiscoveryFlight(g, move.discoveryFlight!, move));
|
g.discoveries = nexusRule(() => completeDiscoveryFlight(g, move.discoveryFlight!, move));
|
||||||
log(g, `${p.name} used the carried Discovery Ornithopter for this movement and removed its token.`);
|
log(g, `${p.name} used the carried Discovery Ornithopter for this movement and removed its token.`);
|
||||||
@@ -16271,6 +16281,7 @@ function completeMove(g: Game, move: MovementOrder) {
|
|||||||
if (flight?.player === id) {
|
if (flight?.player === id) {
|
||||||
flight.completed++;
|
flight.completed++;
|
||||||
if (flight.completed === (flight.mode === 'twoGroups' ? 2 : 1)) {
|
if (flight.completed === (flight.mode === 'twoGroups' ? 2 : 1)) {
|
||||||
|
if (flight.mode === 'twoGroups') p.ornithopterExtraMoves = (p.ornithopterExtraMoves ?? 0) + 1;
|
||||||
finishOrnithopter(g, 'move', completed);
|
finishOrnithopter(g, 'move', completed);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -19640,6 +19651,8 @@ function commitMovementPhaseStart(g: Game, quote: MovementPhaseQuote) {
|
|||||||
for (const p of g.players) {
|
for (const p of g.players) {
|
||||||
p.shipped = false;
|
p.shipped = false;
|
||||||
p.moved = 0;
|
p.moved = 0;
|
||||||
|
delete p.ornithopterExtraMoves;
|
||||||
|
delete p.hajrWindow;
|
||||||
}
|
}
|
||||||
applyAdvisorReleases(g, quote.released);
|
applyAdvisorReleases(g, quote.released);
|
||||||
g.decision = quote.decision;
|
g.decision = quote.decision;
|
||||||
@@ -28354,7 +28367,7 @@ function projectedNexusGuildCunning(g: Game, owner: string) {
|
|||||||
else if (g.response || g.decision || g.truthtrance || g.phaseOpening || g.pendingKarama || g.pendingTreacheryDiscard ||
|
else if (g.response || g.decision || g.truthtrance || g.phaseOpening || g.pendingKarama || g.pendingTreacheryDiscard ||
|
||||||
g.pendingNullentropy || g.pendingShipment || g.pendingHomeworldShipment || g.nexusTraitorPending || g.nexusCards.phase?.stage === 'drawing')
|
g.pendingNullentropy || g.pendingShipment || g.pendingHomeworldShipment || g.nexusTraitorPending || g.nexusCards.phase?.stage === 'drawing')
|
||||||
blocked = 'Finish the current interaction before declaring a second shipment.';
|
blocked = 'Finish the current interaction before declaring a second shipment.';
|
||||||
else if (g.ornithopter || p.moved > (g.hajr.includes(owner) ? 2 : 1))
|
else if (g.ornithopter || p.ornithopterExtraMoves || p.moved > (g.hajr.includes(owner) ? 2 : 1))
|
||||||
blocked = 'Cunning combined with the physical Ornithopter movement card awaits its timing ruling.';
|
blocked = 'Cunning combined with the physical Ornithopter movement card awaits its timing ruling.';
|
||||||
else if (g.nexusGuildCunningHistory?.some(r => r.receipt.turn === g.turn && r.receipt.owner === owner))
|
else if (g.nexusGuildCunningHistory?.some(r => r.receipt.turn === g.turn && r.receipt.owner === owner))
|
||||||
blocked = 'This turn’s Guild Cunning opportunity is already used.';
|
blocked = 'This turn’s Guild Cunning opportunity is already used.';
|
||||||
@@ -30730,6 +30743,12 @@ function applyActionInner(
|
|||||||
);
|
);
|
||||||
requireRule(g.status !== 'finished', 'This game has ended.');
|
requireRule(g.status !== 'finished', 'This game has ended.');
|
||||||
const t = action.type;
|
const t = action.type;
|
||||||
|
// Mandatory movement reactions and other seats' actions do not close the
|
||||||
|
// Advanced owner's immediate Hajr opportunity. Its next voluntary action does.
|
||||||
|
if (g.advanced && p.hajrWindow && !g.response && !g.decision && !g.truthtrance &&
|
||||||
|
!g.phaseOpening && !g.pendingTreacheryDiscard && t !== 'advanceBots' &&
|
||||||
|
!(t === 'card' && p.hand.some(card => card.id === action.card && card.effect === 'hajr')))
|
||||||
|
delete p.hajrWindow;
|
||||||
requireRule(t !== 'nexusIxianReplacementPass' && t !== 'nexusIxianReplacementUse',
|
requireRule(t !== 'nexusIxianReplacementPass' && t !== 'nexusIxianReplacementUse',
|
||||||
'This purchased-card choice is no longer pending.');
|
'This purchased-card choice is no longer pending.');
|
||||||
requireRule(t !== 'guildBetrayalPass' && t !== 'guildBetrayalUse',
|
requireRule(t !== 'guildBetrayalPass' && t !== 'guildBetrayalUse',
|
||||||
@@ -33995,6 +34014,7 @@ function applyActionInner(
|
|||||||
finishShipmentPromises(g, p, null);
|
finishShipmentPromises(g, p, null);
|
||||||
p.shipped = true;
|
p.shipped = true;
|
||||||
p.moved++;
|
p.moved++;
|
||||||
|
p.hajrWindow = {turn:g.turn,move:p.moved};
|
||||||
const name = (world: string) =>
|
const name = (world: string) =>
|
||||||
world === 'homeworld:emperor' ? 'Kaitain' : 'Salusa Secundus';
|
world === 'homeworld:emperor' ? 'Kaitain' : 'Salusa Secundus';
|
||||||
const shifts = quote.populations.after
|
const shifts = quote.populations.after
|
||||||
@@ -34592,6 +34612,8 @@ function applyActionInner(
|
|||||||
advisors: p.advisors,
|
advisors: p.advisors,
|
||||||
hand: p.hand,
|
hand: p.hand,
|
||||||
movesAllowed: movesAllowed(g, p),
|
movesAllowed: movesAllowed(g, p),
|
||||||
|
hajrUsed: g.hajr.includes(p.id),
|
||||||
|
hajrAvailable: hajrAvailable(g,p),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -34601,11 +34623,8 @@ function applyActionInner(
|
|||||||
if (availability && !availability.available)
|
if (availability && !availability.available)
|
||||||
throw new RuleError(availability.reason);
|
throw new RuleError(availability.reason);
|
||||||
if (c.effect === 'hajr') {
|
if (c.effect === 'hajr') {
|
||||||
requireRule(
|
|
||||||
g.ornithopter?.player !== id,
|
|
||||||
'Hajr combined with Ornithopter awaits its timing ruling.',
|
|
||||||
);
|
|
||||||
g.hajr.push(id);
|
g.hajr.push(id);
|
||||||
|
delete p.hajrWindow;
|
||||||
} else if (c.effect === 'ghola') {
|
} else if (c.effect === 'ghola') {
|
||||||
applyGholaEffect(g, p, action, c.id);
|
applyGholaEffect(g, p, action, c.id);
|
||||||
} else if (c.effect === 'harvester') {
|
} else if (c.effect === 'harvester') {
|
||||||
@@ -35585,6 +35604,8 @@ export function viewGame(state: Game, id: string) {
|
|||||||
shipped: p.shipped,
|
shipped: p.shipped,
|
||||||
moved: p.moved,
|
moved: p.moved,
|
||||||
movesAllowed: movesAllowed(g, p),
|
movesAllowed: movesAllowed(g, p),
|
||||||
|
hajrUsed: g.hajr.includes(p.id),
|
||||||
|
hajrAvailable: hajrAvailable(g,p),
|
||||||
revived: p.revived,
|
revived: p.revived,
|
||||||
leaderRevived: p.leaderRevived,
|
leaderRevived: p.leaderRevived,
|
||||||
gholaBlocked: Object.entries(p.gholaBlocked ?? {})
|
gholaBlocked: Object.entries(p.gholaBlocked ?? {})
|
||||||
|
|||||||
@@ -7,6 +7,15 @@ export type MovementCohort = {
|
|||||||
};
|
};
|
||||||
type Counts = Readonly<Record<string, number>>;
|
type Counts = Readonly<Record<string, number>>;
|
||||||
|
|
||||||
|
/** A two-group flight replaces one ordinary entitlement. Only its completed
|
||||||
|
* extra group earns a physical-move credit; Hajr remains an independent move. */
|
||||||
|
export function ordinaryMovementAllowance(hajrUsed: boolean, extraMoves = 0): number {
|
||||||
|
const limit = 1 + Number(hajrUsed) + extraMoves;
|
||||||
|
if (typeof hajrUsed !== 'boolean' || !Number.isSafeInteger(extraMoves) || extraMoves < 0 ||
|
||||||
|
!Number.isSafeInteger(limit)) throw new RangeError('Movement credits must be nonnegative safe integers.');
|
||||||
|
return limit;
|
||||||
|
}
|
||||||
|
|
||||||
function validateCounts(counts: Counts, label: string) {
|
function validateCounts(counts: Counts, label: string) {
|
||||||
if (!counts || typeof counts !== 'object' || Array.isArray(counts))
|
if (!counts || typeof counts !== 'object' || Array.isArray(counts))
|
||||||
throw new TypeError(`${label} must be a location count record.`);
|
throw new TypeError(`${label} must be a location count record.`);
|
||||||
|
|||||||
+19
-12
@@ -901,7 +901,8 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
'A concealed No-Field may move as a group member without exposing its value. A marker moved in the first group cannot also move in the second. Revealing an unmoved marker converts its remaining entitlement to its actual placed forces; revealing a moved marker does not give those forces another move.',
|
'A concealed No-Field may move as a group member without exposing its value. A marker moved in the first group cannot also move in the second. Revealing an unmoved marker converts its remaining entitlement to its actual placed forces; revealing a moved marker does not give those forces another move.',
|
||||||
'A validated declaration moves the physical card from your hand to the public played-card area. It remains there through genuine CHOAM, Ixian or other arrival decisions and is discarded once when the chosen use ends. Random hand effects act on cards still in hand. Ending your movement closes any remaining card movement without undoing a completed group.',
|
'A validated declaration moves the physical card from your hand to the public played-card area. It remains there through genuine CHOAM, Ixian or other arrival decisions and is discarded once when the chosen use ends. Random hand effects act on cards still in hand. Ending your movement closes any remaining card movement without undoing a completed group.',
|
||||||
'The saved event binds retries and the remaining original-group data. Refresh and reconnect preserve that event. After disposal, recovery preserves the completed forces and marker event before opening arrival decisions or advancing an early-ended turn; it does not move the group again. Missing saved group data cannot be reconstructed from merged forces and blocks the damaged continuation instead of allowing reuse.',
|
'The saved event binds retries and the remaining original-group data. Refresh and reconnect preserve that event. After disposal, recovery preserves the completed forces and marker event before opening arrival decisions or advancing an early-ended turn; it does not move the group again. Missing saved group data cannot be reconstructed from merged forces and blocks the damaged continuation instead of allowing reuse.',
|
||||||
'Kulon and the fixed-range card now coexist under a provisional policy: the one-group move stays capped at three territories, while each normal-range group receives Kulon’s bonus. Hajr/prior-move composition and advanced advisor use remain guarded; these development guards are not printed prohibitions. Other unfinished entry and optional-module interactions remain unfinished; full Richese starts are still disabled.',
|
'Under the provisional composition, Ornithopter replaces one unused ordinary or Hajr movement. A prior move stays spent and cannot become the card’s first group retrospectively. One Hajr plus a two-group flight permits three group movements; the Hajr move may reuse a card-moved group. Ending early abandons the unused card group and ends your turn. Multiple physical Hajr cards remain unfinished.',
|
||||||
|
'Advanced Hajr must be played immediately after a completed Force Movement and its required arrival decisions, before your next voluntary game action. A completed card group provisionally supplies this opportunity. Basic retains broader own-turn timing. Kulon leaves the fixed card maximum at three and extends both normal-range groups. Advisor/card use, Guild Cunning/card and wider optional combinations remain unfinished.',
|
||||||
'All four AI profiles use their own projected movement choices and original-group limits. They can use a longer route, split distinct groups, finish interrupted movement and end optional movement when no legal second group remains. Complete Richese-game calibration is not yet certified.',
|
'All four AI profiles use their own projected movement choices and original-group limits. They can use a longer route, split distinct groups, finish interrupted movement and end optional movement when no legal second group remains. Complete Richese-game calibration is not yet certified.',
|
||||||
],
|
],
|
||||||
related: [
|
related: [
|
||||||
@@ -915,32 +916,37 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
area: 'Implementation',
|
area: 'Implementation',
|
||||||
status: 'Partial',
|
status: 'Partial',
|
||||||
detail:
|
detail:
|
||||||
'Both movement modes, played-card custody, exact event continuation, typed original-group quotas, concealed-marker conversion and separate disposal recovery are integrated. Kulon coexistence uses a labeled provisional three-territory cap; Hajr/prior moves and advisor composition remain guarded.',
|
'Both modes retain original event/cohort accounting. One unused normal/Hajr entitlement can start a flight; completed two-group credit preserves unused Hajr afterward. Advanced uses supplied immediate-after-play timing with an explicit online/card-group convention. Multiple Hajr and advisor composition remain unfinished.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
area: 'Player controls',
|
area: 'Player controls',
|
||||||
status: 'Partial',
|
status: 'Partial',
|
||||||
detail:
|
detail:
|
||||||
'Uses the existing board source/destination and multi-sector controls, a mode selector, an inspector and remaining-group guidance. Automatic disposal adds no confirmation.',
|
'Existing source/destination, typed group, mode and inspector controls consume current shared movement availability. Persistent text explains Hajr composition and Advanced timing; automatic disposal adds no confirmation.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
area: 'AI',
|
area: 'AI',
|
||||||
status: 'Partial',
|
status: 'Partial',
|
||||||
detail:
|
detail:
|
||||||
'Four profiles use projected ranges and remaining ordinary/elite/marker quotas without hidden-value access. Complete expansion strength calibration remains unfinished.',
|
'Four profiles use projected ranges, distinct group quotas and earned Hajr movement. Actual owned second-flight and extra-move candidates are legal; strategic tuning remains deferred.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
area: 'Documentation',
|
area: 'Documentation',
|
||||||
status: 'Partial',
|
status: 'Partial',
|
||||||
detail:
|
detail:
|
||||||
'Explains both choices, independent card range, current normal range, distinct groups, public played-card custody and explicit unresolved combinations.',
|
'Explains unused entitlement replacement, per-flight distinctness, later Hajr reuse, fixed/normal range, Advanced source timing and explicit unfinished multiple-card/module combinations.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
area: 'Verification',
|
area: 'Verification',
|
||||||
status: 'Partial',
|
status: 'Partial',
|
||||||
detail:
|
detail:
|
||||||
'Pure quota and engine checks cover twelve holders, both game modes, merged groups, exact marker events, stale state and interrupted faction decisions. Full expansion acceptance remains unverified.',
|
'Hajr checkpoint adds frozen11-file132/132, types/lint/build and12 original-action/rendered-table programmes: actual paid cards/shipments, Basic and Advanced timing, prior normal movement, typed groups, native Baliset, voluntary window closure/reopening and early end. Four minimal policies retain both continuations. Framework aliases/CSS stubs mean no visual/browser/deployed acceptance; full combinations/assurance/modes remain open.',
|
||||||
evidence: [
|
evidence: [
|
||||||
|
'game/ornithopter.ts',
|
||||||
|
'game/card-availability.ts',
|
||||||
|
'tests/fixture-ornithopter-hajr.ts',
|
||||||
|
'tests/ornithopter-hajr-engine.test.ts',
|
||||||
|
'docs/RICHESE_ORNITHOPTER_ENGINE_AUDIT.md',
|
||||||
'tests/ornithopter.test.ts',
|
'tests/ornithopter.test.ts',
|
||||||
'tests/ornithopter-engine.test.ts',
|
'tests/ornithopter-engine.test.ts',
|
||||||
'tests/ornithopter-engine-review.test.ts',
|
'tests/ornithopter-engine-review.test.ts',
|
||||||
@@ -1217,7 +1223,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
: definition.card.effect === 'nullentropyBox'
|
: definition.card.effect === 'nullentropyBox'
|
||||||
? 'Use the Nullentropy Box panel to pay two bank spice for a private discard search. Both modes provisionally permit full-hand retrieval as an atomic exchange that discards the Box and leaves the final hand within its current limit. Provisional Guild refund claims remain guarded.'
|
? 'Use the Nullentropy Box panel to pay two bank spice for a private discard search. Both modes provisionally permit full-hand retrieval as an atomic exchange that discards the Box and leaves the final hand within its current limit. Provisional Guild refund claims remain guarded.'
|
||||||
: definition.card.effect === 'ornithopter'
|
: definition.card.effect === 'ornithopter'
|
||||||
? 'Use the Ornithopter movement controls with a selected group and destination. Provisionally, Kulon coexists without raising the one-group maximum of three; both normal-range groups retain its bonus. Hajr/prior-move composition and advanced advisors remain guarded.'
|
? 'Use the Ornithopter controls with one unused normal/Hajr movement and a selected group/destination. Two card groups stay distinct, while Hajr may reuse their forces afterward. Advanced Hajr follows immediate-after-movement timing. Kulon retains the fixed maximum three and extends both normal groups. Multiple Hajr, advisor/card and wider module combinations remain unfinished.'
|
||||||
: definition.card.effect === 'residualPoison'
|
: definition.card.effect === 'residualPoison'
|
||||||
? 'Use the Residual Poison panel before either combatant commits a leader. A shared preparation step provides an opportunity regardless of hand contents; advanced Harkonnen tables remain guarded pending the secret-captive ruling. In the explicit Semuta preview, a clean death with no readiness or inspection commitments pauses after public disposal for the neutral offer, then resumes the same preparation.'
|
? 'Use the Residual Poison panel before either combatant commits a leader. A shared preparation step provides an opportunity regardless of hand contents; advanced Harkonnen tables remain guarded pending the secret-captive ruling. In the explicit Semuta preview, a clean death with no readiness or inspection commitments pauses after public disposal for the neutral offer, then resumes the same preparation.'
|
||||||
: definition.card.effect === 'stoneBurner'
|
: definition.card.effect === 'stoneBurner'
|
||||||
@@ -1251,7 +1257,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
'The private semuta --discoveries entry composes original Richese/CHOAM and optional Ix Discovery setup, optionally unused Tech3+ and Advanced Strongholds, without Skills, Homeworlds, Nexus or E3. An entitled physical Truthtrance used as Karama offers its original fresh cost before the same price, cancellation, normal-auction or native special effect resumes. Emperor revival already happened and is not repeated. A physical Karama used as Truthtrance offers after its single definite answer. Recovery changes custody, never the printed face or original effect. Controlled Shrine setup evidence is not natural draw/entry or complete-mode acceptance.',
|
'The private semuta --discoveries entry composes original Richese/CHOAM and optional Ix Discovery setup, optionally unused Tech3+ and Advanced Strongholds, without Skills, Homeworlds, Nexus or E3. An entitled physical Truthtrance used as Karama offers its original fresh cost before the same price, cancellation, normal-auction or native special effect resumes. Emperor revival already happened and is not repeated. A physical Karama used as Truthtrance offers after its single definite answer. Recovery changes custody, never the printed face or original effect. Controlled Shrine setup evidence is not natural draw/entry or complete-mode acceptance.',
|
||||||
'The separate fresh semuta --homeworlds entry retains original Richese/CHOAM and classic Homeworld occupation setup with exact CHOAM and no other modules. Paid Box search, clean Distrans transfer, definite Truthtrance answers, used ordinary specials, paid Kaitain disposal, Tupile cleanup and printed Karama canceling native Guild/Emperor/Richese-purchase income offer Semuta. Native printed Kulon, Baliset, La La La, Trip to Gamont and Jubba costs also pause before their original effect: claim/pass adds one movement bonus, denies free allowance without charging/moving forces, stops the uncommitted Baliset move, returns one original board counter before the same CHOAM close, or applies protection before the same recorded storm moves. Printed Karama canceling these powers pauses separately before denial; the retained Worthless fuel is not a candidate. A canceled reactive Baliset resumes its original declared move once; canceled Kulon/La La La/Gamont/Jubba do not apply their effects. A recovered card grants no other faction CHOAM power. Kaitain private groups require commitment before selection; each Tupile holder has a separate cost. Native income denial retains payment and the original occupied-share allocation. Basic unresolved epochs, Kull, other producers and public starts remain guarded.',
|
'The separate fresh semuta --homeworlds entry retains original Richese/CHOAM and classic Homeworld occupation setup with exact CHOAM and no other modules. Paid Box search, clean Distrans transfer, definite Truthtrance answers, used ordinary specials, paid Kaitain disposal, Tupile cleanup and printed Karama canceling native Guild/Emperor/Richese-purchase income offer Semuta. Native printed Kulon, Baliset, La La La, Trip to Gamont and Jubba costs also pause before their original effect: claim/pass adds one movement bonus, denies free allowance without charging/moving forces, stops the uncommitted Baliset move, returns one original board counter before the same CHOAM close, or applies protection before the same recorded storm moves. Printed Karama canceling these powers pauses separately before denial; the retained Worthless fuel is not a candidate. A canceled reactive Baliset resumes its original declared move once; canceled Kulon/La La La/Gamont/Jubba do not apply their effects. A recovered card grants no other faction CHOAM power. Kaitain private groups require commitment before selection; each Tupile holder has a separate cost. Native income denial retains payment and the original occupied-share allocation. Basic unresolved epochs, Kull, other producers and public starts remain guarded.',
|
||||||
'Legal completed CHOAM market sales also offer their used card in the Homeworld profile. Worthless cashout keeps its original two-spice credit; a non-Worthless duplicate keeps its three-spice credit and the original same-name witness stays held. The high-Tupile Worthless-sale prohibition still applies, including duplicate Worthless sales. Claim/pass cannot repeat payout, undo the actual reserve shipment or replace the original market close; no new bank-income interpretation is made.',
|
'Legal completed CHOAM market sales also offer their used card in the Homeworld profile. Worthless cashout keeps its original two-spice credit; a non-Worthless duplicate keeps its three-spice credit and the original same-name witness stays held. The high-Tupile Worthless-sale prohibition still applies, including duplicate Worthless sales. Claim/pass cannot repeat payout, undo the actual reserve shipment or replace the original market close; no new bank-income interpretation is made.',
|
||||||
'Final Ornithopter disposal also offers its used card in the Homeworld profile after the one-group range-three or original two-group movement completes, including an early end with no useful original group. Claim/pass cannot move those counters again or replay the paid native shipment; prior-move/Hajr, advisor and Homeworld-transfer boundaries remain. Clean Sapho Once Around and unstarted Movement first/last costs likewise offer after reordering the finite unacted queue. Earlier bids/funding and physical storm order stay committed, and the same next bidder or combined turn resumes once. Cyclic auctions, partly completed turns, first-versus-Guild and late battle timing are not opened.',
|
'Final Ornithopter disposal also offers its used card in the Homeworld profile after the one-group range-three or original two-group movement completes, including an early end with no useful original group. Claim/pass cannot move those counters again or replay the paid native shipment; unused movement entitlements retain the shared Hajr composition, while advisor and Homeworld-transfer boundaries remain. Clean Sapho Once Around and unstarted Movement first/last costs likewise offer after reordering the finite unacted queue. Earlier bids/funding and physical storm order stay committed, and the same next bidder or combined turn resumes once. Cyclic auctions, partly completed turns, first-versus-Guild and late battle timing are not opened.',
|
||||||
'Original Homeworld battle disposal offers accepted losing cards, later mandatory winning cards and optional winner-selected cards as separate fresh events. Commit and exact selection recover at most one other player’s newly discarded card; unselected cards stay in the pile. The original paid deployments, accepted result, leader deaths and casualty obligations remain committed, with the same native winner cleanup and rewards afterward. A recovered card cannot replay combat or consume another original opportunity.',
|
'Original Homeworld battle disposal offers accepted losing cards, later mandatory winning cards and optional winner-selected cards as separate fresh events. Commit and exact selection recover at most one other player’s newly discarded card; unselected cards stay in the pile. The original paid deployments, accepted result, leader deaths and casualty obligations remain committed, with the same native winner cleanup and rewards afterward. A recovered card cannot replay combat or consume another original opportunity.',
|
||||||
'Clean Homeworld Residual Poison disposal offers its used card after exactly one native random eligible leader enters the Tanks, with no spice bounty or force loss; claim/pass resumes the original shared preparation and does not redraw the victim. Early Sapho aggressor disposal likewise keeps the original participants/chooser and changed ordinary tie priority, while clean between-battle first/last disposal keeps earlier resolved battles and only reorders remaining chooser opportunities. Physical storm order is unchanged. Readied or inspected Poison costs, late Sapho timing and Advanced Harkonnen captive disclosure remain outside these clean reactions.',
|
'Clean Homeworld Residual Poison disposal offers its used card after exactly one native random eligible leader enters the Tanks, with no spice bounty or force loss; claim/pass resumes the original shared preparation and does not redraw the victim. Early Sapho aggressor disposal likewise keeps the original participants/chooser and changed ordinary tie priority, while clean between-battle first/last disposal keeps earlier resolved battles and only reorders remaining chooser opportunities. Physical storm order is unchanged. Readied or inspected Poison costs, late Sapho timing and Advanced Harkonnen captive disclosure remain outside these clean reactions.',
|
||||||
'Printed Karama canceling original CHOAM opening income or native Inflation placement also offers its used card in the Homeworld profile. Claim/pass completes the same denial once: unpaid income stays withheld, and the original placement attempt remains spent with no token. Clean normal-lot printed Karama purchase and free winning-bid payment likewise offer their costs before exactly one original reserved card arrives without spice payment. Earlier bids, the normal pool, Richese cache, original wallets and later native controls stay committed. Richese lots, BG Worthless conversion, Kull and other cancellations remain separate.',
|
'Printed Karama canceling original CHOAM opening income or native Inflation placement also offers its used card in the Homeworld profile. Claim/pass completes the same denial once: unpaid income stays withheld, and the original placement attempt remains spent with no token. Clean normal-lot printed Karama purchase and free winning-bid payment likewise offer their costs before exactly one original reserved card arrives without spice payment. Earlier bids, the normal pool, Richese cache, original wallets and later native controls stay committed. Richese lots, BG Worthless conversion, Kull and other cancellations remain separate.',
|
||||||
@@ -2556,14 +2562,15 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
id: 'card-hajr',
|
id: 'card-hajr',
|
||||||
title: 'Hajr',
|
title: 'Hajr',
|
||||||
category: 'Cards',
|
category: 'Cards',
|
||||||
coverage: 'Implemented',
|
coverage: 'Partial',
|
||||||
summary: 'Make an additional force move during your movement turn.',
|
summary: 'Make an additional force move during your movement turn.',
|
||||||
steps: [
|
steps: [
|
||||||
'Play during your shipment and movement turn.',
|
'In Basic, play during your shipment and movement turn. Advanced requires the immediate opportunity after a completed Force Movement and its required arrival decisions, before your next voluntary game action.',
|
||||||
'Make a second legal group move. Normal source, range and destination restrictions still apply.',
|
'Make one additional legal group move, using the same or another group. Normal source, range and destination restrictions still apply. A completed Ornithopter group provisionally supplies the Advanced timing opportunity.',
|
||||||
|
'Ornithopter replaces one unused movement entitlement, so one Hajr and a two-group flight permit three group movements. Groups must differ within that flight, while the later Hajr move may reuse them. Multiple physical Hajr plays in one turn remain an unfinished prototype boundary; the supplied passage does not establish a once-per-turn prohibition.',
|
||||||
'A saved play preserves the extra movement allowance. Recovery neither grants another move nor finishes your movement turn.',
|
'A saved play preserves the extra movement allowance. Recovery neither grants another move nor finishes your movement turn.',
|
||||||
],
|
],
|
||||||
related: ['movement'],
|
related: ['movement', 'ornithopter-movement'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'card-ghola',
|
id: 'card-ghola',
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ void test('Weather Control stays available after dials and confirmations, but va
|
|||||||
'omitting selection asks about timing only',
|
'omitting selection asks about timing only',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
void test('Hajr requires the active movement owner and cannot grant a third move', () => {
|
void test('Hajr requires the active movement owner and grants only one additional ordinary entitlement', () => {
|
||||||
agrees(
|
agrees(
|
||||||
'hajr',
|
'hajr',
|
||||||
(g) => {
|
(g) => {
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||||
|
import { gameDistance, location, splitLocation, TERRITORIES } from '../game/board';
|
||||||
|
import type { OrnithopterMode } from '../game/ornithopter';
|
||||||
|
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
||||||
|
import { withClassicDiscoveryNexusLottery } from './fixture-discovery-classic-nexus';
|
||||||
|
|
||||||
|
export const HAJR_ORNITHOPTER = 'richese-ornithopter';
|
||||||
|
export type OrnithopterHajrStep = { actor: string; action: Action };
|
||||||
|
export type OrnithopterHajrOptions = { advanced?: boolean; interruption?: boolean; amount?: 3 | 4 };
|
||||||
|
export const ornithopterHajrPlayer = (game: Game, actor = 'emperor') => {
|
||||||
|
const player = game.players.find(player => player.id === actor); assert.ok(player); return player;
|
||||||
|
};
|
||||||
|
export const ornithopterHajrClean = (game: Game) => !game.response && !game.phaseOpening &&
|
||||||
|
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard;
|
||||||
|
export function actOrnithopterHajr(game: Game, actor: string, action: Action,
|
||||||
|
actions: OrnithopterHajrStep[] = []): Game {
|
||||||
|
actions.push({ actor, action: structuredClone(action) });
|
||||||
|
return withClassicDiscoveryNexusLottery(() => applyAction(game, actor, action));
|
||||||
|
}
|
||||||
|
function nextOrnithopterHajrStep(game: Game): OrnithopterHajrStep | null {
|
||||||
|
if (!game.response && !game.phaseOpening && game.decision) {
|
||||||
|
const decision = game.decision;
|
||||||
|
if (decision.kind === 'richeseCache') return { actor: decision.player, action: { type: 'decision',
|
||||||
|
event: game.richeseBidding!.event, card: HAJR_ORNITHOPTER, method: 'onceAround', direction: 'clockwise' } };
|
||||||
|
if (decision.kind === 'richeseUnbid') return { actor: decision.player,
|
||||||
|
action: { type: 'decision', event: game.richeseBidding!.event, keep: false } };
|
||||||
|
if (decision.kind === 'choamMovement') return { actor: decision.player, action: { type: 'decision', decline: true } };
|
||||||
|
}
|
||||||
|
if (ornithopterHajrClean(game) && game.richeseAuction?.cardId === HAJR_ORNITHOPTER &&
|
||||||
|
!game.richeseAuction.outcome && game.richeseAuction.active === 'emperor' && game.richeseAuction.bid === 0)
|
||||||
|
return { actor: 'emperor', action: { type: 'richeseBid', event: game.richeseAuction.event, amount: 1, allyPayment: 0 } };
|
||||||
|
if (ornithopterHajrClean(game) && game.auction?.active === 'emperor' && game.auction.bid === 0 &&
|
||||||
|
game.auction.cards[game.auction.index].effect === 'hajr')
|
||||||
|
return { actor: 'emperor', action: { type: 'bid', amount: 1, allyPayment: 0 } };
|
||||||
|
return nextRicheseStrongholdsNativeStep(game);
|
||||||
|
}
|
||||||
|
export function advanceOrnithopterHajr(game: Game, until: (state: Game) => boolean,
|
||||||
|
actions: OrnithopterHajrStep[] = []): Game {
|
||||||
|
for (let count = 0; count < 2000; count++) {
|
||||||
|
if (until(game)) return game;
|
||||||
|
assert.notEqual(game.status, 'finished', 'The original Ornithopter/Hajr programme ended before its next owned control');
|
||||||
|
const step = nextOrnithopterHajrStep(game); assert.ok(step, 'Complete the real private plan before continuing');
|
||||||
|
game = actOrnithopterHajr(game, step.actor, step.action, actions);
|
||||||
|
}
|
||||||
|
throw Error('The original Ornithopter/Hajr programme did not reach its finite boundary');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Original host, native cache/ordinary paid lots and actual reserve shipments.
|
||||||
|
* An optional native CHOAM arrival interruption is funded before the pilot's
|
||||||
|
* turn; no dealt card, board counter, wallet, clock or reaction is installed. */
|
||||||
|
export function createOrnithopterHajrFixture(options: OrnithopterHajrOptions = {}) {
|
||||||
|
const advanced = options.advanced ?? false;
|
||||||
|
let game = createGame('ORNITHOPTERHAJR', newPlayer('richese', 'Richese', 'richese'), advanced, ['choam']);
|
||||||
|
const seats = options.interruption ? ['choam', 'emperor', 'guild'] as const : ['emperor', 'guild'] as const;
|
||||||
|
for (const faction of seats) joinGame(game, newPlayer(faction, faction, faction));
|
||||||
|
const actions: OrnithopterHajrStep[] = [];
|
||||||
|
const act = (actor: string, action: Action) => { game = actOrnithopterHajr(game, actor, action, actions); };
|
||||||
|
const reach = (until: (state: Game) => boolean) => { game = advanceOrnithopterHajr(game, until, actions); };
|
||||||
|
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||||
|
const lobby = structuredClone(game);
|
||||||
|
act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
|
||||||
|
const setup = structuredClone(game);
|
||||||
|
reach(state => state.status === 'playing');
|
||||||
|
const afterSetup = structuredClone(game), front: Game['spiceDeck'] = [];
|
||||||
|
for (let count = 0; count < (advanced ? 2 : 1); count++) {
|
||||||
|
const at = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(at >= 0);
|
||||||
|
front.push(game.spiceDeck.splice(at, 1)[0]);
|
||||||
|
}
|
||||||
|
game.spiceDeck.unshift(...front);
|
||||||
|
reach(state => state.phase === 2 && ornithopterHajrClean(state));
|
||||||
|
const at = game.deck.findIndex(card => card.effect === 'hajr'); assert.ok(at >= 0, 'The original Hajr must remain undealt');
|
||||||
|
const hajr = game.deck[at].id; game.deck.unshift(game.deck.splice(at, 1)[0]);
|
||||||
|
const beforePurchases = structuredClone(game);
|
||||||
|
reach(state => ornithopterHajrPlayer(state).hand.some(card => card.id === HAJR_ORNITHOPTER) &&
|
||||||
|
ornithopterHajrPlayer(state).hand.some(card => card.id === hajr));
|
||||||
|
const acquired = structuredClone(game), shipments: { before: Game; step: OrnithopterHajrStep; after: Game }[] = [];
|
||||||
|
const ship = (actor: string, to: string, sector: number, amount: number, elite = 0) => {
|
||||||
|
const before = structuredClone(game), step: OrnithopterHajrStep = { actor,
|
||||||
|
action: { type: 'ship', territory: to, sector, amount, elite, allyPayment: 0 } };
|
||||||
|
act(actor, step.action); reach(ornithopterHajrClean);
|
||||||
|
shipments.push({ before, step, after: structuredClone(game) });
|
||||||
|
};
|
||||||
|
if (options.interruption) {
|
||||||
|
reach(state => state.phase === 5 && state.active === 'choam' && ornithopterHajrClean(state));
|
||||||
|
ship('choam', 'oh_gap', 9, 1);
|
||||||
|
}
|
||||||
|
reach(state => state.phase === 5 && state.active === 'emperor' && ornithopterHajrClean(state));
|
||||||
|
ship('emperor', 'imperial_basin', 9, options.amount ?? 4, advanced ? 2 : 0);
|
||||||
|
return { game, lobby, setup, afterSetup, beforePurchases, acquired, hajr, actions, shipments,
|
||||||
|
owner: 'emperor', source: 'imperial_basin:9', interruption: options.interruption ?? false,
|
||||||
|
staging: ['Original native setup and paid cache/ordinary auction acquire both cards. Only unopened Hajr/Spice positions and scalar lotteries are selected; original funded shipments provide every physical source.'] };
|
||||||
|
}
|
||||||
|
export type OrnithopterHajrFixture = ReturnType<typeof createOrnithopterHajrFixture>;
|
||||||
|
|
||||||
|
/** Use current physical typed custody and real storm geography, without city
|
||||||
|
* access or any staged speed bonus. A forced destination supports the genuine
|
||||||
|
* native CHOAM interruption; other targets are empty printed sand or rock. */
|
||||||
|
export function ornithopterHajrMove(game: Game, from: string, options: {
|
||||||
|
range?: number; mode?: OrnithopterMode; to?: string; elite?: number;
|
||||||
|
} = {}): Action {
|
||||||
|
const own = ornithopterHajrPlayer(game), range = options.range ?? 1;
|
||||||
|
assert.ok((own.forces[from] ?? 0) > 0);
|
||||||
|
const to = options.to ?? TERRITORIES.filter(place => place.type === 'sand' || place.type === 'rock')
|
||||||
|
.flatMap(place => place.sectors.map(sector => location(place.id, sector)))
|
||||||
|
.find(to => game.players.every(player => Object.entries(player.forces).every(([key, count]) =>
|
||||||
|
count === 0 || splitLocation(key).territory !== splitLocation(to).territory)) &&
|
||||||
|
gameDistance(game, from, to, key => splitLocation(key).sector === game.storm) === range);
|
||||||
|
assert.ok(to, `The original group needs an empty printed range-${range} destination`);
|
||||||
|
const flight = viewGame(game, 'emperor').ornithopter?.active;
|
||||||
|
return { type: 'move', from, amount: 1, elite: options.elite ?? ((own.elites?.forces[from] ?? 0) > 0 ? 1 : 0),
|
||||||
|
...splitLocation(to), ...(options.mode ? { movementCard: HAJR_ORNITHOPTER, ornithopter: options.mode }
|
||||||
|
: flight ? { ornithopterEvent: flight.event } : {}) };
|
||||||
|
}
|
||||||
|
export function playOrnithopterHajr(game: Game, fixture: OrnithopterHajrFixture,
|
||||||
|
actions: OrnithopterHajrStep[] = []): Game {
|
||||||
|
game = actOrnithopterHajr(game, fixture.owner, { type: 'card', card: fixture.hajr }, actions);
|
||||||
|
return advanceOrnithopterHajr(game, ornithopterHajrClean, actions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Complete actual card groups then the independent Hajr continuation. Before
|
||||||
|
* is Basic face timing; Advanced uses Hajr after a completed Force Movement.
|
||||||
|
* A prior ordinary move is never reclassified as part of the later flight. */
|
||||||
|
export function completeOrnithopterHajr(fixture: OrnithopterHajrFixture, options: {
|
||||||
|
mode?: OrnithopterMode; timing?: 'before' | 'during' | 'after'; priorNormal?: boolean;
|
||||||
|
} = {}) {
|
||||||
|
const mode = options.mode ?? 'twoGroups', timing = options.timing ?? 'after';
|
||||||
|
assert.ok(mode === 'twoGroups' || timing !== 'during', 'A one-group flight completes before another timing window');
|
||||||
|
assert.ok(!fixture.game.advanced || timing !== 'before', 'Advanced Hajr follows an actual completed Force Movement');
|
||||||
|
const actions: OrnithopterHajrStep[] = [], moves: { before: Game; step: OrnithopterHajrStep; after: Game }[] = [];
|
||||||
|
let game = structuredClone(fixture.game), prior: Game | null = null, pendingArrival: Game | null = null;
|
||||||
|
const move = (from: string, settings: Parameters<typeof ornithopterHajrMove>[2] = {}) => {
|
||||||
|
const before = structuredClone(game), step: OrnithopterHajrStep = { actor: fixture.owner, action: ornithopterHajrMove(game, from, settings) };
|
||||||
|
game = actOrnithopterHajr(game, step.actor, step.action, actions);
|
||||||
|
if (game.decision?.kind === 'choamMovement') pendingArrival = structuredClone(game);
|
||||||
|
game = advanceOrnithopterHajr(game, ornithopterHajrClean, actions);
|
||||||
|
moves.push({ before, step, after: structuredClone(game) });
|
||||||
|
return location(String(step.action.territory), Number(step.action.sector));
|
||||||
|
};
|
||||||
|
let firstSource = fixture.source;
|
||||||
|
if (options.priorNormal) {
|
||||||
|
firstSource = move(fixture.source); prior = structuredClone(game);
|
||||||
|
game = playOrnithopterHajr(game, fixture, actions);
|
||||||
|
} else if (timing === 'before') game = playOrnithopterHajr(game, fixture, actions);
|
||||||
|
const beforeFlight = structuredClone(game), firstDestination = move(firstSource, {
|
||||||
|
mode, range: mode === 'range3' ? 3 : 1,
|
||||||
|
...(fixture.interruption ? { to: 'oh_gap:9' } : {}),
|
||||||
|
});
|
||||||
|
const first = structuredClone(game);
|
||||||
|
if (!options.priorNormal && timing === 'during') game = playOrnithopterHajr(game, fixture, actions);
|
||||||
|
if (mode === 'twoGroups') move(fixture.source);
|
||||||
|
const flightComplete = structuredClone(game);
|
||||||
|
if (!options.priorNormal && timing === 'after') game = playOrnithopterHajr(game, fixture, actions);
|
||||||
|
const beforeExtra = structuredClone(game);
|
||||||
|
if (!options.priorNormal) move(firstDestination);
|
||||||
|
const completed = structuredClone(game);
|
||||||
|
game = actOrnithopterHajr(game, fixture.owner, { type: 'endMovement' }, actions);
|
||||||
|
return { game, completed, prior, beforeFlight, first, flightComplete, beforeExtra, firstDestination,
|
||||||
|
pendingArrival, moves, actions, mode, timing };
|
||||||
|
}
|
||||||
@@ -207,6 +207,7 @@ void test('all profiles honor mode/ownership guards, existing interaction priori
|
|||||||
},
|
},
|
||||||
(g: Game) => {
|
(g: Game) => {
|
||||||
g.hajr = ['p'];
|
g.hajr = ['p'];
|
||||||
|
g.players[0].moved = 2;
|
||||||
},
|
},
|
||||||
(g: Game) => {
|
(g: Game) => {
|
||||||
g.active = 'o';
|
g.active = 'o';
|
||||||
|
|||||||
@@ -67,11 +67,11 @@ void test('played card escrow, owner-only remaining quotas and ended movement su
|
|||||||
assert.equal(ended.active,'q');
|
assert.equal(ended.active,'q');
|
||||||
});
|
});
|
||||||
void test('invalid source, blocked route, committed card and explicitly unresolved combinations are immutable',()=>{
|
void test('invalid source, blocked route, committed card and explicitly unresolved combinations are immutable',()=>{
|
||||||
for(const change of ['range','storm','prior','hajr','advisor','gift','wrongMode'] as const){
|
for(const change of ['range','storm','prior','advisor','gift','wrongMode'] as const){
|
||||||
const g=fixture(change==='advisor'?'beneGesserit':'emperor',change==='advisor');
|
const g=fixture(change==='advisor'?'beneGesserit':'emperor',change==='advisor');
|
||||||
let key=destination(g,origin,3);const extra:Partial<Action>={movementCard:card,ornithopter:'range3'};
|
let key=destination(g,origin,3);const extra:Partial<Action>={movementCard:card,ornithopter:'range3'};
|
||||||
if(change==='range')key=destination(g,origin,4);if(change==='storm')g.storm=splitLocation(key).sector;
|
if(change==='range')key=destination(g,origin,4);if(change==='storm')g.storm=splitLocation(key).sector;
|
||||||
if(change==='prior')g.players[0].moved=1;if(change==='hajr')g.hajr=['p'];
|
if(change==='prior')g.players[0].moved=1;
|
||||||
if(change==='advisor'){g.players[0].advisors={red_chasm:{}};g.players[1].forces={[origin]:1};}
|
if(change==='advisor'){g.players[0].advisors={red_chasm:{}};g.players[1].forces={[origin]:1};}
|
||||||
if(change==='wrongMode')extra.ornithopter='both';
|
if(change==='wrongMode')extra.ornithopter='both';
|
||||||
if(change==='gift')g.pendingRicheseGift={event:'reserved',intent:{owner:'p',recipient:'q',cardId:card},turn:g.turn,phase:g.phase,resume:{response:null,decision:null,pendingKarama:null,phaseOpening:null}} as Game['pendingRicheseGift'];
|
if(change==='gift')g.pendingRicheseGift={event:'reserved',intent:{owner:'p',recipient:'q',cardId:card},turn:g.turn,phase:g.phase,resume:{response:null,decision:null,pendingKarama:null,phaseOpening:null}} as Game['pendingRicheseGift'];
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import { viewGame, type Game } from '../game/engine';
|
||||||
|
import { botActions } from '../game/bots';
|
||||||
|
import { location } from '../game/board';
|
||||||
|
import {
|
||||||
|
HAJR_ORNITHOPTER, actOrnithopterHajr, advanceOrnithopterHajr, completeOrnithopterHajr,
|
||||||
|
createOrnithopterHajrFixture, ornithopterHajrClean, ornithopterHajrMove,
|
||||||
|
ornithopterHajrPlayer as player, playOrnithopterHajr, type OrnithopterHajrFixture,
|
||||||
|
} from './fixture-ornithopter-hajr';
|
||||||
|
|
||||||
|
const ownView = (game: Game) => viewGame(game, 'emperor').players.find(player => player.id === 'emperor')!;
|
||||||
|
function source(f: OrnithopterHajrFixture) {
|
||||||
|
assert.ok(f.actions.some(step => step.actor === f.owner && step.action.type === 'richeseBid' && step.action.amount === 1));
|
||||||
|
assert.ok(f.actions.some(step => step.actor === f.owner && step.action.type === 'bid' && step.action.amount === 1));
|
||||||
|
assert.ok(player(f.game).hand.some(card => card.id === HAJR_ORNITHOPTER));
|
||||||
|
assert.ok(player(f.game).hand.some(card => card.id === f.hajr));
|
||||||
|
const shipment = f.shipments.find(shipment => shipment.step.actor === f.owner)!;
|
||||||
|
assert.equal(player(shipment.before).spice - player(shipment.after).spice, Number(shipment.step.action.amount) * 2,
|
||||||
|
'The real source is an originally funded desert shipment, not a staged board group');
|
||||||
|
assert.equal(player(f.game).forces[f.source], Number(shipment.step.action.amount));
|
||||||
|
assert.equal(ownView(f.game).moved, 0);
|
||||||
|
assert.equal(ownView(f.game).movesAllowed, 1);
|
||||||
|
assert.equal(ownView(f.game).hajrUsed, false);
|
||||||
|
assert.equal(ownView(f.game).hajrAvailable, !f.game.advanced);
|
||||||
|
}
|
||||||
|
function settled(f: OrnithopterHajrFixture, completed: Game, moves: number) {
|
||||||
|
assert.equal(player(completed).moved, moves);
|
||||||
|
assert.equal(ownView(completed).movesAllowed, moves);
|
||||||
|
assert.equal(ownView(completed).hajrUsed, true);
|
||||||
|
assert.equal(ownView(completed).hajrAvailable, false);
|
||||||
|
assert.equal(completed.ornithopter ?? null, null);
|
||||||
|
assert.equal(completed.discard.filter(card => card.id === HAJR_ORNITHOPTER).length, 1);
|
||||||
|
assert.equal(completed.discard.filter(card => card.id === f.hajr).length, 1);
|
||||||
|
assert.equal(player(completed).reserves, player(f.game).reserves);
|
||||||
|
assert.equal(player(completed).spice, player(f.game).spice);
|
||||||
|
assert.equal(player(completed).tanks, player(f.game).tanks);
|
||||||
|
assert.equal(Object.values(player(completed).forces).reduce((sum, count) => sum + count, 0),
|
||||||
|
Object.values(player(f.game).forces).reduce((sum, count) => sum + count, 0));
|
||||||
|
assert.throws(() => actOrnithopterHajr(completed, f.owner, { type: 'card', card: f.hajr }),
|
||||||
|
'The consumed physical Hajr cannot be replayed to add a fourth move');
|
||||||
|
}
|
||||||
|
|
||||||
|
void test('genuine Basic paid Ornithopter composes with Hajr before, during or after two distinct card groups', () => {
|
||||||
|
for (const timing of ['before', 'during', 'after'] as const) {
|
||||||
|
const f = createOrnithopterHajrFixture();
|
||||||
|
source(f);
|
||||||
|
const result = completeOrnithopterHajr(f, { timing });
|
||||||
|
assert.equal(result.moves.length, 3);
|
||||||
|
assert.equal(player(result.first).moved, 1);
|
||||||
|
assert.equal(result.first.ornithopter!.completed, 1);
|
||||||
|
assert.equal(player(result.flightComplete).moved, 2);
|
||||||
|
assert.equal(result.flightComplete.ornithopter ?? null, null);
|
||||||
|
assert.equal(ownView(result.beforeExtra).movesAllowed, 3);
|
||||||
|
assert.equal(result.moves[2].step.action.from, result.firstDestination,
|
||||||
|
'The independent Hajr move may reuse the first card group after the flight ends');
|
||||||
|
assert.equal(result.moves[2].step.action.ornithopterEvent, undefined);
|
||||||
|
assert.notEqual(result.game.active, f.owner);
|
||||||
|
settled(f, result.completed, 3);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('genuine Advanced Hajr follows a completed typed card group and retains its later ordinary move', () => {
|
||||||
|
const f = createOrnithopterHajrFixture({ advanced: true });
|
||||||
|
source(f);
|
||||||
|
assert.throws(() => actOrnithopterHajr(f.game, f.owner, { type: 'card', card: f.hajr }), /Force Movement|move|movement|immediately/i);
|
||||||
|
const result = completeOrnithopterHajr(f, { timing: 'during' });
|
||||||
|
assert.equal(ownView(result.first).hajrAvailable, true);
|
||||||
|
assert.equal(result.moves[0].step.action.elite, 1);
|
||||||
|
assert.equal(result.moves[1].step.action.elite, 1);
|
||||||
|
assert.equal(result.moves[2].step.action.elite, 1);
|
||||||
|
assert.equal(Object.values(player(result.completed).elites!.forces).reduce((sum, count) => sum + count, 0), 2);
|
||||||
|
settled(f, result.completed, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('a real ordinary move followed by Hajr grants a fresh two-group flight without retroactively counting that move', () => {
|
||||||
|
for (const advanced of [false, true]) {
|
||||||
|
const f = createOrnithopterHajrFixture({ advanced });
|
||||||
|
source(f);
|
||||||
|
const result = completeOrnithopterHajr(f, { timing: 'during', priorNormal: true });
|
||||||
|
assert.equal(player(result.prior!).moved, 1);
|
||||||
|
assert.equal(result.prior!.ornithopter ?? null, null);
|
||||||
|
assert.equal(ownView(result.beforeFlight).movesAllowed, 2);
|
||||||
|
assert.equal(result.first.ornithopter!.startingMove, 1);
|
||||||
|
assert.equal(result.first.ornithopter!.completed, 1);
|
||||||
|
assert.equal(player(result.first).moved, 2);
|
||||||
|
assert.equal(result.moves[1].step.action.from,
|
||||||
|
location(String(result.moves[0].step.action.territory), Number(result.moves[0].step.action.sector)),
|
||||||
|
'Units moved by the earlier ordinary entitlement may join a new flight');
|
||||||
|
assert.equal(result.moves.length, 3);
|
||||||
|
settled(f, result.completed, 3);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('a genuine range-three card flight replaces one entitlement while same-flight repeated groups remain illegal', () => {
|
||||||
|
const f = createOrnithopterHajrFixture();
|
||||||
|
source(f);
|
||||||
|
const ranged = completeOrnithopterHajr(f, { mode: 'range3', timing: 'after' });
|
||||||
|
assert.equal(ranged.moves.length, 2);
|
||||||
|
assert.equal(player(ranged.flightComplete).moved, 1);
|
||||||
|
settled(f, ranged.completed, 2);
|
||||||
|
const firstMove = ornithopterHajrMove(f.game, f.source, { mode: 'twoGroups' });
|
||||||
|
let game = actOrnithopterHajr(structuredClone(f.game), f.owner, firstMove);
|
||||||
|
game = advanceOrnithopterHajr(game, ornithopterHajrClean);
|
||||||
|
const firstDestination = location(String(firstMove.territory), Number(firstMove.sector));
|
||||||
|
const repeated = ornithopterHajrMove(game, firstDestination);
|
||||||
|
assert.throws(() => actOrnithopterHajr(game, f.owner, repeated), /cohort|unmoved|original|force/i);
|
||||||
|
game = playOrnithopterHajr(game, f);
|
||||||
|
game = actOrnithopterHajr(game, f.owner, ornithopterHajrMove(game, f.source));
|
||||||
|
game = advanceOrnithopterHajr(game, ornithopterHajrClean);
|
||||||
|
assert.equal(game.ornithopter ?? null, null);
|
||||||
|
assert.equal(ownView(game).movesAllowed, 3);
|
||||||
|
game = actOrnithopterHajr(game, f.owner, ornithopterHajrMove(game, firstDestination));
|
||||||
|
game = advanceOrnithopterHajr(game, ornithopterHajrClean);
|
||||||
|
settled(f, game, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('a real Advanced Baliset pause preserves Hajr timing until an owner action closes it and the next group reopens it', () => {
|
||||||
|
const f = createOrnithopterHajrFixture({ advanced: true, interruption: true, amount: 3 });
|
||||||
|
source(f);
|
||||||
|
assert.equal(player(f.game, 'choam').forces['oh_gap:9'], 1);
|
||||||
|
assert.ok(player(f.game).spice >= 1, 'The smaller real shipment leaves its original bribe funding');
|
||||||
|
const firstMove = ornithopterHajrMove(f.game, f.source, { mode: 'twoGroups', to: 'oh_gap:9' });
|
||||||
|
let game = actOrnithopterHajr(structuredClone(f.game), f.owner, firstMove);
|
||||||
|
assert.equal(game.decision?.kind, 'choamMovement');
|
||||||
|
assert.equal(game.decision?.player, 'choam');
|
||||||
|
assert.equal(player(game).moved, 0);
|
||||||
|
assert.equal(game.ornithopter!.completed, 0);
|
||||||
|
assert.throws(() => actOrnithopterHajr(game, f.owner, { type: 'card', card: f.hajr }));
|
||||||
|
game = actOrnithopterHajr(game, 'choam', { type: 'decision', decline: true });
|
||||||
|
game = advanceOrnithopterHajr(game, ornithopterHajrClean);
|
||||||
|
assert.equal(player(game).moved, 1);
|
||||||
|
assert.equal(ownView(game).hajrAvailable, true);
|
||||||
|
game = actOrnithopterHajr(game, f.owner, { type: 'bribe', target: 'guild', amount: 1 });
|
||||||
|
assert.equal(ownView(game).hajrAvailable, false);
|
||||||
|
assert.throws(() => actOrnithopterHajr(game, f.owner, { type: 'card', card: f.hajr }), /Force Movement|move|movement|immediately/i);
|
||||||
|
game = actOrnithopterHajr(game, f.owner, ornithopterHajrMove(game, f.source));
|
||||||
|
game = advanceOrnithopterHajr(game, ornithopterHajrClean);
|
||||||
|
assert.equal(player(game).moved, 2);
|
||||||
|
assert.equal(ownView(game).hajrAvailable, true);
|
||||||
|
game = playOrnithopterHajr(game, f);
|
||||||
|
game = actOrnithopterHajr(game, f.owner, ornithopterHajrMove(game, 'oh_gap:9'));
|
||||||
|
game = advanceOrnithopterHajr(game, ornithopterHajrClean);
|
||||||
|
assert.equal(player(game).moved, 3);
|
||||||
|
assert.equal(ownView(game).movesAllowed, 3);
|
||||||
|
assert.equal(player(game).spice, player(f.game).spice - 1);
|
||||||
|
assert.equal(game.discard.filter(card => card.id === f.hajr).length, 1);
|
||||||
|
assert.equal(game.discard.filter(card => card.id === HAJR_ORNITHOPTER).length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('all four minimal policies retain legal second-flight and earned Hajr movement candidates from original pending views', () => {
|
||||||
|
const f = createOrnithopterHajrFixture();
|
||||||
|
let first = actOrnithopterHajr(structuredClone(f.game), f.owner,
|
||||||
|
ornithopterHajrMove(f.game, f.source, { mode: 'twoGroups' }));
|
||||||
|
first = advanceOrnithopterHajr(first, ornithopterHajrClean);
|
||||||
|
first = playOrnithopterHajr(first, f);
|
||||||
|
const complete = actOrnithopterHajr(first, f.owner, ornithopterHajrMove(first, f.source));
|
||||||
|
const ready = advanceOrnithopterHajr(complete, ornithopterHajrClean);
|
||||||
|
assert.equal(ownView(ready).movesAllowed, 3);
|
||||||
|
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||||
|
for (const game of [first, ready]) {
|
||||||
|
const view = viewGame(game, f.owner);
|
||||||
|
view.players.find(player => player.id === f.owner)!.bot = difficulty;
|
||||||
|
const action = botActions(view).find(action => action.type === 'move');
|
||||||
|
assert.ok(action, `${difficulty} must keep an owned legal ${game.ornithopter ? 'second flight' : 'Hajr'} movement`);
|
||||||
|
assert.equal(typeof action.ornithopterEvent === 'string', !!game.ornithopter);
|
||||||
|
const moved = actOrnithopterHajr(structuredClone(game), f.owner, action);
|
||||||
|
assert.ok(player(moved).moved === player(game).moved + 1 || moved.response || moved.decision,
|
||||||
|
'Each real policy candidate completes its group or enters its original reaction');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -163,7 +163,7 @@ for (const advanced of [false, true]) for (const { mode, early } of paths) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} Homeworld original prior-move, Hajr and fixed maximum-three guards remain real`, () => {
|
for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basic'} Homeworld prior move needs an unused entitlement and Hajr preserves the fixed maximum three`, () => {
|
||||||
const f = createSemutaHomeworldOrnithopterFixture('range3', advanced);
|
const f = createSemutaHomeworldOrnithopterFixture('range3', advanced);
|
||||||
const before = structuredClone(f.beforeFirstMove);
|
const before = structuredClone(f.beforeFirstMove);
|
||||||
assert.throws(() => applyAction(before, f.owner, { ...f.firstMove, ...splitLocation(f.tooFarDestination) }));
|
assert.throws(() => applyAction(before, f.owner, { ...f.firstMove, ...splitLocation(f.tooFarDestination) }));
|
||||||
@@ -174,14 +174,20 @@ for (const advanced of [false, true]) void test(`${advanced ? 'Advanced' : 'Basi
|
|||||||
assert.equal(player(moved, f.owner).hand.some(c => c.id === f.card), true);
|
assert.equal(player(moved, f.owner).hand.some(c => c.id === f.card), true);
|
||||||
assert.throws(() => applyAction(moved, f.owner, { ...f.firstMove, from: f.secondDestination,
|
assert.throws(() => applyAction(moved, f.owner, { ...f.firstMove, from: f.secondDestination,
|
||||||
...splitLocation(f.origin) }));
|
...splitLocation(f.origin) }));
|
||||||
const hajrGame = structuredClone(before);
|
const hajrGame = structuredClone(advanced ? moved : before);
|
||||||
const hajr = holdSemutaHomeworldChoamCard(hajrGame, f.owner, 'Hajr');
|
const hajr = holdSemutaHomeworldChoamCard(hajrGame, f.owner, 'Hajr');
|
||||||
const played = applyAction(hajrGame, f.owner, { type: 'card', card: hajr });
|
const played = applyAction(hajrGame, f.owner, { type: 'card', card: hajr });
|
||||||
const allowed = advanceSemutaHomeworldChoam(played,
|
const allowed = advanceSemutaHomeworldChoam(played,
|
||||||
g => !g.pendingTreacheryDiscard && !g.response && !g.decision && !g.phaseOpening);
|
g => !g.pendingTreacheryDiscard && !g.response && !g.decision && !g.phaseOpening);
|
||||||
assert.equal(player(allowed, f.owner).moved, 0);
|
assert.equal(player(allowed, f.owner).moved, advanced ? 1 : 0);
|
||||||
assert.ok(allowed.hajr.includes(f.owner));
|
assert.ok(allowed.hajr.includes(f.owner));
|
||||||
assert.throws(() => applyAction(allowed, f.owner, f.firstMove));
|
|
||||||
assert.equal(player(allowed, f.owner).hand.some(c => c.id === f.card), true);
|
assert.equal(player(allowed, f.owner).hand.some(c => c.id === f.card), true);
|
||||||
|
const declared = applyAction(allowed, f.owner, advanced
|
||||||
|
? { ...f.firstMove, from: f.secondDestination, ...splitLocation(f.origin) }
|
||||||
|
: f.firstMove);
|
||||||
|
const completed = advanceSemutaHomeworldChoam(declared,
|
||||||
|
g => !g.pendingTreacheryDiscard && !g.response && !g.decision && !g.phaseOpening);
|
||||||
|
assert.equal(player(completed, f.owner).moved, advanced ? 2 : 1);
|
||||||
assertCounters(allowed, f.setup);
|
assertCounters(allowed, f.setup);
|
||||||
|
assertCounters(completed, f.setup);
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user