Connect occupied Tupile capacity and original cleanup
This commit is contained in:
1 parent
d637dbaf63
commit
3e79021a6e
19 files changed
+1512
-14
No files matched your search
@@ -27,6 +27,7 @@ import { GrummanCollection } from './grumman-collection';
|
|||||||
import { HomeworldOccupiedIncome } from './homeworld-occupied-income';
|
import { HomeworldOccupiedIncome } from './homeworld-occupied-income';
|
||||||
import { HomeworldOccupiedPercentage } from './homeworld-occupied-percentage';
|
import { HomeworldOccupiedPercentage } from './homeworld-occupied-percentage';
|
||||||
import { HomeworldOccupiedBonus } from './homeworld-occupied-bonus';
|
import { HomeworldOccupiedBonus } from './homeworld-occupied-bonus';
|
||||||
|
import { HomeworldTupileCleanup } from './homeworld-tupile-cleanup';
|
||||||
import { TupileIntelligence } from './tupile-intelligence';
|
import { TupileIntelligence } from './tupile-intelligence';
|
||||||
import { NexusCards } from './nexus-cards';
|
import { NexusCards } from './nexus-cards';
|
||||||
import { NexusTraitors } from './nexus-traitors';
|
import { NexusTraitors } from './nexus-traitors';
|
||||||
@@ -1108,6 +1109,25 @@ export function GameTable({
|
|||||||
<a href="/rules?topic=homeworlds#homeworlds">Preview rules and limits</a>
|
<a href="/rules?topic=homeworlds#homeworlds">Preview rules and limits</a>
|
||||||
</output>
|
</output>
|
||||||
)}
|
)}
|
||||||
|
{g.homeworldTupilePreview && (
|
||||||
|
<output className="notice block">
|
||||||
|
Occupied Tupile development preview · the occupier and reciprocal ally
|
||||||
|
each gain one server-derived hand slot; losing it requires the original
|
||||||
|
owner to discard eligible held cards to the normal limit. Advanced rulebook
|
||||||
|
page 22 retains occupation benefits until the occupier’s last own force
|
||||||
|
leaves, not on contests, native high population or turn changes; a new sole
|
||||||
|
occupier after departure starts a new epoch. Basic unresolved expiry and
|
||||||
|
competing-owner cases remain guarded. No extra card or spice is granted.
|
||||||
|
Not complete or certified Homeworld rules.{' '}
|
||||||
|
<a href="/rules?topic=homeworlds#homeworlds">Preview rules and limits</a>
|
||||||
|
</output>
|
||||||
|
)}
|
||||||
|
{g.homeworldTupileBlocked && (
|
||||||
|
<output className="notice block">{g.homeworldTupileBlocked}</output>
|
||||||
|
)}
|
||||||
|
{g.homeworldTupileCleanup?.blocked && (g.decision?.kind !== 'homeworldTupileCleanup' || g.decision.player !== g.me || g.decision.event !== g.homeworldTupileCleanup.event) && (
|
||||||
|
<HomeworldTupileCleanup game={g} act={act} busy={true} />
|
||||||
|
)}
|
||||||
{g.homeworldOccupiedPercentage?.blocked && (g.decision?.kind !== 'homeworldOccupiedPercentage' || g.decision.player !== g.me) && (
|
{g.homeworldOccupiedPercentage?.blocked && (g.decision?.kind !== 'homeworldOccupiedPercentage' || g.decision.player !== g.me) && (
|
||||||
<HomeworldOccupiedPercentage game={g} act={act} busy={true} />
|
<HomeworldOccupiedPercentage game={g} act={act} busy={true} />
|
||||||
)}
|
)}
|
||||||
@@ -2486,6 +2506,8 @@ export function GameTable({
|
|||||||
? 'Occupied Homeworld percentage income'
|
? 'Occupied Homeworld percentage income'
|
||||||
: g.decision.kind === 'homeworldOccupiedBonus'
|
: g.decision.kind === 'homeworldOccupiedBonus'
|
||||||
? 'Occupied Giedi Prime bonus card'
|
? 'Occupied Giedi Prime bonus card'
|
||||||
|
: g.decision.kind === 'homeworldTupileCleanup'
|
||||||
|
? 'Occupied Tupile · return to the normal hand limit'
|
||||||
: g.decision.kind === 'leaderSkillVisibility'
|
: g.decision.kind === 'leaderSkillVisibility'
|
||||||
? 'Position your skilled leader'
|
? 'Position your skilled leader'
|
||||||
: g.decision.kind === 'mentatQuestion'
|
: g.decision.kind === 'mentatQuestion'
|
||||||
@@ -2819,6 +2841,8 @@ export function GameTable({
|
|||||||
<HomeworldOccupiedPercentage game={g} act={act} busy={busy} />
|
<HomeworldOccupiedPercentage game={g} act={act} busy={busy} />
|
||||||
) : g.decision.kind === 'homeworldOccupiedBonus' ? (
|
) : g.decision.kind === 'homeworldOccupiedBonus' ? (
|
||||||
<HomeworldOccupiedBonus game={g} act={act} busy={busy} />
|
<HomeworldOccupiedBonus game={g} act={act} busy={busy} />
|
||||||
|
) : g.decision.kind === 'homeworldTupileCleanup' ? (
|
||||||
|
<HomeworldTupileCleanup game={g} act={act} busy={busy} />
|
||||||
) : g.decision.kind === 'ecazSpice' ? (
|
) : g.decision.kind === 'ecazSpice' ? (
|
||||||
g.ecazSpice?.allocation ? (
|
g.ecazSpice?.allocation ? (
|
||||||
<EcazSpice
|
<EcazSpice
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import type { Action, GameView } from '@/game/engine';
|
||||||
|
import { homeworldCard } from '@/game/homeworld-cards';
|
||||||
|
import { CardInspector, CardRules } from './card-inspector';
|
||||||
|
import { HomeworldFace } from './homeworld-cards';
|
||||||
|
import { Button } from './ui/button';
|
||||||
|
|
||||||
|
type Props = { game: GameView; act: (action: Action) => void; busy: boolean };
|
||||||
|
type Offer = NonNullable<GameView['homeworldTupileCleanup']>;
|
||||||
|
|
||||||
|
export function HomeworldTupileCleanup({ game, act, busy }: Props) {
|
||||||
|
const offer = game.homeworldTupileCleanup;
|
||||||
|
if (!offer || offer.player !== game.me) return null;
|
||||||
|
const authorized = game.decision?.kind === 'homeworldTupileCleanup' &&
|
||||||
|
game.decision.player === game.me && game.decision.event === offer.event;
|
||||||
|
if (!authorized && !offer.blocked) return null;
|
||||||
|
return <CleanupChoice key={`${game.me}:${offer.event}`} game={game} offer={offer}
|
||||||
|
act={act} busy={busy || !authorized} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CleanupChoice({ game, offer, act, busy }: Props & { offer: Offer }) {
|
||||||
|
const [selection, setSelection] = useState<string[]>([]);
|
||||||
|
const me = game.players.find((player) => player.id === game.me);
|
||||||
|
const eligibleIds = new Set(offer.eligibleCards);
|
||||||
|
const cards = (me?.hand ?? []).filter((card) => eligibleIds.has(card.id));
|
||||||
|
const heldIds = new Set(cards.map((card) => card.id));
|
||||||
|
const selected = selection.filter((id) => heldIds.has(id));
|
||||||
|
const countsValid = Number.isSafeInteger(offer.limit) && offer.limit >= 0 &&
|
||||||
|
Number.isSafeInteger(offer.excess) && offer.excess > 0;
|
||||||
|
const blocked = offer.blocked ?? (!countsValid
|
||||||
|
? 'The original hand-limit cleanup is unavailable.'
|
||||||
|
: heldIds.size < offer.excess
|
||||||
|
? 'Not enough eligible held cards are available. Committed and reserved cards remain protected.'
|
||||||
|
: null);
|
||||||
|
const valid = !blocked && selected.length === offer.excess &&
|
||||||
|
new Set(selected).size === selected.length;
|
||||||
|
const source = homeworldCard('tupile');
|
||||||
|
return (
|
||||||
|
<section aria-label="Your occupied Tupile hand-limit cleanup" className="space-y-3">
|
||||||
|
<h3>{offer.playerName} · Tupile hand-limit cleanup</h3>
|
||||||
|
<p>
|
||||||
|
The occupied Tupile hand slot is no longer available. Discard exactly {offer.excess}{' '}
|
||||||
|
{offer.excess === 1 ? 'card' : 'cards'} from your eligible held cards to return to
|
||||||
|
the normal limit of {offer.limit}.
|
||||||
|
</p>
|
||||||
|
<p className="fine">
|
||||||
|
Only the server-offered original held cards can be selected. Committed and reserved
|
||||||
|
cards are protected. This settles the lost slot; normal discard effects remain separate.
|
||||||
|
</p>
|
||||||
|
<p className="notice">
|
||||||
|
Development preview · Advanced rulebook page 22 retains the original occupation
|
||||||
|
benefit until the occupier’s last own force leaves; contests, native high population
|
||||||
|
and turn changes do not end it. A new sole occupier after departure starts a new
|
||||||
|
occupation epoch. Basic unresolved expiry and competing-owner cases remain guarded.
|
||||||
|
Not complete or certified Homeworld rules.
|
||||||
|
</p>
|
||||||
|
<p className="fine break-all">Original occupation cleanup event: {offer.event}</p>
|
||||||
|
{source && (
|
||||||
|
<details className="rounded-lg border border-[#a88b60]/50 p-3">
|
||||||
|
<summary className="cursor-pointer py-2">Inspect Tupile source card</summary>
|
||||||
|
<HomeworldFace card={source} />
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
<output className="notice block" aria-live="polite">
|
||||||
|
Normal limit: {offer.limit} · Cards owed: {offer.excess} · Selected: {selected.length}
|
||||||
|
</output>
|
||||||
|
{blocked && <output className="notice block">{blocked}</output>}
|
||||||
|
<form className="space-y-3" onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!busy && valid) act({ type: 'decision', event: offer.event, cards: selected });
|
||||||
|
}}>
|
||||||
|
<fieldset className="space-y-3">
|
||||||
|
<legend>Your eligible held cards</legend>
|
||||||
|
{cards.map((card) => (
|
||||||
|
<div key={card.id} className="space-y-2 rounded-lg border border-[#a88b60]/50 p-3">
|
||||||
|
<label className="decision-checkbox">
|
||||||
|
<input type="checkbox" checked={selected.includes(card.id)}
|
||||||
|
disabled={busy || !!blocked || (!selected.includes(card.id) && selected.length >= offer.excess)}
|
||||||
|
onChange={(event) => setSelection(event.target.checked
|
||||||
|
? [...selected, card.id]
|
||||||
|
: selected.filter((id) => id !== card.id))} />
|
||||||
|
<span>{card.name}</span>
|
||||||
|
</label>
|
||||||
|
<CardRules card={card} />
|
||||||
|
<CardInspector card={card} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!cards.length && <p className="muted">No eligible held cards are available.</p>}
|
||||||
|
</fieldset>
|
||||||
|
{!blocked && <Button type="submit" className="game-action" disabled={busy || !valid}>
|
||||||
|
Discard {offer.excess} {offer.excess === 1 ? 'card' : 'cards'} to the normal limit
|
||||||
|
</Button>}
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,39 @@ open mode gates.
|
|||||||
|
|
||||||
## Current checkpoint and work
|
## Current checkpoint and work
|
||||||
|
|
||||||
|
**Occupied Tupile slots / normal-limit cleanup — bounded Development Verified:**
|
||||||
|
fresh native CHOAM/Homeworld entries alone initialize the source marker/ledger;
|
||||||
|
older occupation profiles remain unchanged. One validated slot per occupier/
|
||||||
|
reciprocal ally feeds every existing capacity consumer. Advanced last departure
|
||||||
|
and ally removal queue original holder-selected excess discards; Basic unknown
|
||||||
|
expiry freezes its proved ledger/flags and blocks rather than guesses cleanup.
|
||||||
|
Known occupation suppresses new low queries; proved expiry restores eligible
|
||||||
|
contact without clearing historical private answers or lifetime usage.
|
||||||
|
|
||||||
|
Source19/affected143 including guide cases, types/lint/build, all4 legal policies
|
||||||
|
and actual native programs pass. Original Harkonnen buys its ninth card without
|
||||||
|
a tenth bonus; real Tupile→Junction return1 queues both old-holder debts and
|
||||||
|
preserves remaining movement, cards, typed forces and one price. Completed
|
||||||
|
automatic invoices are accepted by the program helper; Basic genuinely observed
|
||||||
|
continuous qualification retains one lease, unlike an unobserved turn boundary.
|
||||||
|
|
||||||
|
Ten genuine samples on unchanged52ccb930 tree: allfive six-seat Advanced finish
|
||||||
|
4,339/4,358/114 accepted/attempts/JSON; four of five five-seat Basic finish
|
||||||
|
2,430/2,449/64, allfive total2,527/2,547/66. Atreides5Basic retains original
|
||||||
|
seed20263432 contested Southern guard. Natural samples containzero owed cleanup
|
||||||
|
choices, so changed-path proof is the original native program/human return.
|
||||||
|
|
||||||
|
Original backed-up **D54MY8JJ v18/setup**, real turn-two Guild/Harkonnen Nexus
|
||||||
|
alliance and labelled conserved post-Charity positions retain actors/deck/clock.
|
||||||
|
Human390px Harkonnen8/9 buys5 atv26 (12→7) and holds9/9. Actual Guild last visitor
|
||||||
|
returns1 to Junction for1 (6→5), then original Cheap Hero5→4 and ally Jubba9→8
|
||||||
|
settle atv30 without another move/price. v31 ends only Guild’s original turn.
|
||||||
|
A separate conserved native1/Fremen1 contact permits **v33** private3 spice/
|
||||||
|
0 weapons; refresh keeps lifetime use and reveals no rival panel. No root390px
|
||||||
|
overflow, reset, public activation or played-game upgrade. [Exact contract/evidence](HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md#occupied-tupile-slots-and-original-cleanup--5-october-2026).
|
||||||
|
Full Homeworlds/overlays, Basic/CHOAM rulings, exceptional Duke, assurance and
|
||||||
|
deployed acceptance remain open.
|
||||||
|
|
||||||
**Occupied Homeworld economics / Bidding — bounded Development Verified:**
|
**Occupied Homeworld economics / Bidding — bounded Development Verified:**
|
||||||
original fresh2..6 Basic/Advanced setup and selected decks; Advanced uses the
|
original fresh2..6 Basic/Advanced setup and selected decks; Advanced uses the
|
||||||
authorized supplied p22 sole qualification retained until its last force leaves,
|
authorized supplied p22 sole qualification retained until its last force leaves,
|
||||||
|
|||||||
@@ -42,8 +42,9 @@ occupied card selection is not authority to invent a sale after cancellation.
|
|||||||
Actual Collection percentage credits feed the original once-per-phase Giedi
|
Actual Collection percentage credits feed the original once-per-phase Giedi
|
||||||
spice hook. Private producer proofs and suspended invoices stay server-private.
|
spice hook. Private producer proofs and suspended invoices stay server-private.
|
||||||
Original receipt-free occupation captures may admit a new source lazily; no past
|
Original receipt-free occupation captures may admit a new source lazily; no past
|
||||||
percentage is back-awarded. Public starts, played-game conversion, other modules,
|
percentage is back-awarded. A separate fresh marker now connects
|
||||||
occupied Tupile capacity/cleanup and exceptional Duke custody remain separate.
|
[occupied Tupile slots/cleanup and low-query restoration](HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md#occupied-tupile-slots-and-original-cleanup--5-october-2026).
|
||||||
|
Public starts, played-game conversion, other modules and exceptional Duke custody remain separate.
|
||||||
See [percentage receipts](../game/homeworld-occupied-percentage.ts),
|
See [percentage receipts](../game/homeworld-occupied-percentage.ts),
|
||||||
[Bidding authority](../game/homeworld-occupied-bidding.ts) and
|
[Bidding authority](../game/homeworld-occupied-bidding.ts) and
|
||||||
[original consumer programs](../tests/homeworld-occupied-producers-engine.test.ts).
|
[original consumer programs](../tests/homeworld-occupied-producers-engine.test.ts).
|
||||||
@@ -216,6 +217,8 @@ The following is an implementation design derived from those timing requirements
|
|||||||
|
|
||||||
A public qualification record needs no hand, Traitor, Face Dancer or sealed-plan data. Malformed initialized evidence must fail validation; a legacy save lacking history cannot be claimed to prove that no earlier qualifier existed. Resolving entitlement by scanning current armies alone would discard the distinction the publisher created.
|
A public qualification record needs no hand, Traitor, Face Dancer or sealed-plan data. Malformed initialized evidence must fail validation; a legacy save lacking history cannot be claimed to prove that no earlier qualifier existed. Resolving entitlement by scanning current armies alone would discard the distinction the publisher created.
|
||||||
|
|
||||||
|
<a id="questions-the-retrieved-sources-do-not-settle"></a>
|
||||||
|
|
||||||
## Basic source questions and historical publisher gaps
|
## Basic source questions and historical publisher gaps
|
||||||
|
|
||||||
| Concrete situation | Missing decision; do not silently substitute a policy |
|
| Concrete situation | Missing decision; do not silently substitute a policy |
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ Collection, original Caladan/Ix/Richese/Giedi Bidding ownership and printed
|
|||||||
Wallach/Tleilax/Grumman/Salusa protections. Advanced follows the authorized supplied
|
Wallach/Tleilax/Grumman/Salusa protections. Advanced follows the authorized supplied
|
||||||
rulebook p22: qualify alone, retain until the last occupying force leaves.
|
rulebook p22: qualify alone, retain until the last occupying force leaves.
|
||||||
Basic still guards its publisher-source lifecycle questions. Original history,
|
Basic still guards its publisher-source lifecycle questions. Original history,
|
||||||
native population, invoices and private audiences remain separate. Tupile capacity,
|
native population, invoices and private audiences remain separate.
|
||||||
exceptional Duke, other modules, normal starts and full acceptance stay open.
|
[Fresh occupied Tupile capacity/cleanup](HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md#occupied-tupile-slots-and-original-cleanup--5-october-2026)
|
||||||
|
uses its own source lease; exceptional Duke, other modules, normal starts and full acceptance stay open.
|
||||||
See the [source cutover and original consumers](HOMEWORLD_OCCUPATION_RULES.md#occupied-economics-bidding-and-advanced-source-cutover--5-october-2026).
|
See the [source cutover and original consumers](HOMEWORLD_OCCUPATION_RULES.md#occupied-economics-bidding-and-advanced-source-cutover--5-october-2026).
|
||||||
|
|
||||||
## Module scope and component inventory
|
## Module scope and component inventory
|
||||||
|
|||||||
@@ -2,7 +2,85 @@
|
|||||||
|
|
||||||
Runtime checkpoint, 10 September 2026. Native CHOAM can now request and retain a private Tupile intelligence observation in a game with proven setup history. This implements the bounded disclosure described in the [source contract](HOMEWORLD_TUPILE_INTELLIGENCE_RULES.md); occupation entitlement, expiry and occupied income remain unresolved. Advanced and unfinished expansion starts, publication and complete-rules release retain the gates in [README](../README.md).
|
Runtime checkpoint, 10 September 2026. Native CHOAM can now request and retain a private Tupile intelligence observation in a game with proven setup history. This implements the bounded disclosure described in the [source contract](HOMEWORLD_TUPILE_INTELLIGENCE_RULES.md); occupation entitlement, expiry and occupied income remain unresolved. Advanced and unfinished expansion starts, publication and complete-rules release retain the gates in [README](../README.md).
|
||||||
|
|
||||||
## Request and historical answer
|
## Occupied Tupile slots and original cleanup — 5 October 2026
|
||||||
|
|
||||||
|
The fresh original `homeworld-occupation` entry now separately enables Tupile
|
||||||
|
capacity when native CHOAM is seated. `homeworldTupilePreview` and the original
|
||||||
|
lease ledger are initialized at that unused entry only. Older occupation profiles
|
||||||
|
are not converted and no current garrison supplies a missing source.
|
||||||
|
|
||||||
|
The proved occupier and current reciprocal ally each receive **one** hand slot:
|
||||||
|
regular4→5 or Harkonnen8→9. The same server-derived flag feeds every existing
|
||||||
|
hand-capacity consumer, including auctions, gifts, searches, exchanges and actual
|
||||||
|
bonus receivers; clients and bots never add a second slot. Original Atomics
|
||||||
|
reduction composes with the native limit, not with a fabricated faction identity.
|
||||||
|
The native CHOAM cannot ally with its own occupier to receive this benefit.
|
||||||
|
|
||||||
|
Advanced uses the authorized supplied physical p22 retention rule. Losing the
|
||||||
|
last force closes the original source epoch; ally removal/replacement closes the
|
||||||
|
former ally’s grant. Original pending/completed cleanup events identify the lost
|
||||||
|
lease and holder. A later lease cannot lend new space before its older cleanup
|
||||||
|
settles. Basic unknown expiry/competition/native restoration preserves its last
|
||||||
|
proved ledger/flags and blocks source-dependent controls; it never guesses a new
|
||||||
|
holder or forces an unsupported discard. A genuinely observed continuous Basic
|
||||||
|
turn qualification retains its one lease rather than stacking another slot.
|
||||||
|
|
||||||
|
Each overfull old holder selects exactly its actual excess held IDs to reach the
|
||||||
|
normal limit. No random discard, duplicate item, new draw or second payment is
|
||||||
|
introduced. Protected/committed cards stay unavailable. Queued cleanup begins at
|
||||||
|
a settled original control boundary after active battle/arrival/card obligations;
|
||||||
|
the original phase opening is preserved, and remaining movement is not consumed.
|
||||||
|
This scheduling/old-holder ordering is explicit application composition, not a
|
||||||
|
new retrieved publisher priority FAQ. Normal discard effects remain separate.
|
||||||
|
Reading a view or replaying an old event cannot dispose of another card.
|
||||||
|
|
||||||
|
The fresh marker also makes low-intelligence availability use this authority.
|
||||||
|
Known occupation suppresses new questions; proved Advanced departure restores
|
||||||
|
the low opportunity when actual contact/population permit it. Past private
|
||||||
|
answers and lifetime faction usage remain intact. Unmarked profiles retain the
|
||||||
|
historical source guard below.
|
||||||
|
|
||||||
|
Original programs prove two actual owed cleanups, all4 legal policies, private
|
||||||
|
choices/JSON/replay rejection, restored native low inquiry and once-per-faction
|
||||||
|
use. An actual native Harkonnen auction buys its ninth card but draws no tenth.
|
||||||
|
Another original paid return moves the last typed visitor from Tupile to Junction
|
||||||
|
for1 spice; both normal-limit cleanups preserve the unused original movement,
|
||||||
|
force inventory, physical cards and one price.
|
||||||
|
|
||||||
|
Human original CLI **D54MY8JJ v18/setup** preserves the selected deck, actors and
|
||||||
|
starting choices. Real second-turn Nexus earns Guild/Harkonnen alliance before a
|
||||||
|
labelled conserved post-Charity position. At390px, Harkonnen8/9 bids5 and reaches
|
||||||
|
**v26 hand9/9**, spice12→7, with no extra card. Actual Guild return from Tupile
|
||||||
|
to Junction costs1 (6→5), then its private Cheap Hero discard5→4 atv29 precedes
|
||||||
|
Harkonnen’s own Jubba Cloak discard9→8 atv30. The real counter returns once;
|
||||||
|
Guild remains active with its shipment used and movement unused. Refresh shows
|
||||||
|
4/4 and original movement controls without root390px overflow. Only this QA
|
||||||
|
record changes; no reset, credential disclosure or played-game conversion.
|
||||||
|
|
||||||
|
Source cases19/related143 including guidance, types/lint/build and original
|
||||||
|
runtime smoke pass. After the real Guild turn ends atv31, a separate labelled
|
||||||
|
conserved contact restores one native force and places one unqualified Fremen
|
||||||
|
beside it; no new occupier is created. Human **v33** records its private3 spice/
|
||||||
|
0 weapons once, while a rival receives no panel. Refresh retains the historical
|
||||||
|
observation and already-used-faction reason; no request is repeated.
|
||||||
|
|
||||||
|
Ten genuine original samples run on unchanged tree
|
||||||
|
`52ccb9305255ecf7234be7a98aea43f7cf44630215639042c50bb0474e93e892`.
|
||||||
|
Allfive six-seat Advanced samples finish4,339 accepted/4,358 attempts/114 JSON.
|
||||||
|
Four of five five-seat Basic samples finish2,430/2,449/64; allfive total
|
||||||
|
2,527/2,547/66. The exact Atreides5Basic seed20263432 capture retains its existing
|
||||||
|
contested Southern occupation guard. No source weakening or favorable replacement
|
||||||
|
seed. These games exercise continuation but contain **zero owed Tupile cleanup
|
||||||
|
choices**; the native programs and human actual return, not those counts, prove
|
||||||
|
the changed cleanup path. Full-mode/zero-rejection claims remain excluded.
|
||||||
|
|
||||||
|
See [lease source](../game/homeworld-occupied-tupile.ts),
|
||||||
|
[real consumers](../tests/homeworld-occupied-tupile-runtime.test.ts) and
|
||||||
|
[private cleanup controls](../components/homeworld-tupile-cleanup.tsx).
|
||||||
|
Full Homeworld/module combinations, pending Basic/CHOAM rulings, normal starts,
|
||||||
|
assurance and deployed acceptance remain separate.
|
||||||
|
|
||||||
|
## Historical request and answer checkpoint
|
||||||
|
|
||||||
The engine accepts `{ type: 'tupileIntelligence', target, category }`, where `target` identifies a seated opposing faction and `category` is `weapons` or `defenses`. The server validates current eligibility before reading the answer or consuming use:
|
The engine accepts `{ type: 'tupileIntelligence', target, category }`, where `target` identifies a seated opposing faction and `category` is `weapons` or `defenses`. The server validates current eligibility before reading the answer or consuming use:
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,42 @@
|
|||||||
# Dune implementation status
|
# Dune implementation status
|
||||||
|
|
||||||
|
## 5 October 2026 — occupied Tupile capacity, original cleanup and low authority
|
||||||
|
|
||||||
|
Fresh native CHOAM/Homeworld audit setup alone marks Tupile slots and the original
|
||||||
|
lease ledger. A server-derived backed flag extends all existing real hand-limit
|
||||||
|
consumers once; old profiles gain nothing. Original Advanced source/reciprocal
|
||||||
|
holder loss retires the lease and queues owner-selected normal-limit excess.
|
||||||
|
Pending old debt cannot borrow a new lease; Basic unknown source preserves its
|
||||||
|
proved state and blocks rather than invents cleanup. Native low intelligence
|
||||||
|
uses known current authority only in this marker, without clearing old answers/use.
|
||||||
|
|
||||||
|
Source19/affected143 including guides, types/lint/build, all4 legal policies and
|
||||||
|
original programs pass. Actual ordinary auction admits Harkonnen card9/no card10;
|
||||||
|
one real paid foreign return queues both normal-limit cleanups and retains its
|
||||||
|
remaining movement/counter/price. Fixtures now distinguish a genuinely observed
|
||||||
|
Basic boundary from unobserved expiry, and accept a completed original invoice
|
||||||
|
without manufacturing a pending sale. No wording/error-text pins or fake card.
|
||||||
|
|
||||||
|
Ten genuine samples on unchanged
|
||||||
|
`52ccb9305255ecf7234be7a98aea43f7cf44630215639042c50bb0474e93e892`:
|
||||||
|
five six-seat Advanced complete4,339 accepted/4,358 attempts/114 JSON;
|
||||||
|
four of five five-seat Basic complete2,430/2,449/64, allfive2,527/2,547/66.
|
||||||
|
Original Atreides5Basic20263432 remains captured at its contested Southern guard.
|
||||||
|
Natural samples containzero owed cleanup choices; native programs/human actual
|
||||||
|
departure supply changed-path proof. No favorable seed or complete-mode claim.
|
||||||
|
|
||||||
|
Original backed-up CLI D54MY8JJ v18 preserves deck/actors/clock; a real second-turn
|
||||||
|
Nexus earns Guild/Harkonnen alliance before labelled conserved positions.
|
||||||
|
390px human Harkonnen8/9 bids5 and holds9/9 atv26, wallet12→7/no tenth bonus.
|
||||||
|
Real last Guild visitor returns to Junction for1 (6→5); Cheap Hero5→4 then allied
|
||||||
|
Jubba9→8 settle atv30 with the original movement still available. v31 finishes
|
||||||
|
only that turn. A separately labelled native1/Fremen1 contact permits private
|
||||||
|
v33 inquiry3 spice/0 weapons; refresh preserves used-faction/history and no rival
|
||||||
|
panel. No overflow/reset/public start/save conversion; private artifacts outside Git.
|
||||||
|
[Canonical lease/consumer/evidence](HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md#occupied-tupile-slots-and-original-cleanup--5-october-2026).
|
||||||
|
Full combinations, pending Basic/CHOAM and exceptional Duke, final assurance and
|
||||||
|
deployed acceptance remain open.
|
||||||
|
|
||||||
## 5 October 2026 — occupied Homeworld economics, Bidding and Advanced retention
|
## 5 October 2026 — occupied Homeworld economics, Bidding and Advanced retention
|
||||||
|
|
||||||
The authorized supplied p22 resolves Advanced qualification/retention: sole
|
The authorized supplied p22 resolves Advanced qualification/retention: sole
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP
|
|||||||
|
|
||||||
| Topic | Decision to preserve | Detailed authority and evidence |
|
| Topic | Decision to preserve | Detailed authority and evidence |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Stable Homeworld occupation | Fresh original Homeworlds alone and source history. Advanced uses authorized p22 sole qualification retained until last-force departure; Basic keeps historical lifecycle guards. Actual bank/percentage income, immediate sharing, Caladan/Ix/Richese/Giedi original ownership and Wallach/Tleilax/Grumman/Salusa defenses are connected. No inferred current controller, past-income back-award, invented canceled-auction count, public/save conversion or unrelated overlay. Tupile capacity and exceptional Duke remain separate. | [Source cutover and original consumers](HOMEWORLD_OCCUPATION_RULES.md#occupied-economics-bidding-and-advanced-source-cutover--5-october-2026) |
|
| Stable Homeworld occupation | Fresh original Homeworlds alone and source history. Advanced uses authorized p22 sole qualification retained until last-force departure; Basic keeps historical lifecycle guards. Actual bank/percentage income, sharing, Caladan/Ix/Richese/Giedi ownership and native defenses are connected. Native CHOAM fresh entries separately mark original Tupile slots, owner-selected normal-limit cleanup and low-query restoration; old profiles are not upgraded. No inferred controller, back-award, invented canceled-auction count, public/save conversion or unrelated overlay. Exceptional Duke remains separate. | [Source cutover](HOMEWORLD_OCCUPATION_RULES.md#occupied-economics-bidding-and-advanced-source-cutover--5-october-2026), [Tupile original lease and consumers](HOMEWORLD_TUPILE_INTELLIGENCE_RUNTIME.md#occupied-tupile-slots-and-original-cleanup--5-october-2026) |
|
||||||
| Standalone E3 Nexus / Skills / original components | Fresh Ecaz OR Moritani/classic ecaz33/all14/all12, Basic/Advanced2..6, original Tech3+/Advanced Strongholds2+. Original Duke/assassination/Terror, chosen-plan Advanced Occupy and separate physical armies remain. Ecaz-led Suk rescues only its own fixed casualties; Diplomat uses the selected faction's own undialed commitment. Owner-pool skill composition is an explicit implementation inference, not a combined-skill FAQ. E3 pairs/Harkonnen Advanced/HW/previews/shared-Duke training/enhanced relocation and pending rulings remain guarded. | [Source and verified scope](NEXUS_CARD_RULES.md#standalone-e3-nexus-skills-and-original-components--5-october-2026), [coalition contract](ECAZ_OCCUPY_RULES.md#standalone-nexus--skills-coalition--5-october-2026) |
|
| Standalone E3 Nexus / Skills / original components | Fresh Ecaz OR Moritani/classic ecaz33/all14/all12, Basic/Advanced2..6, original Tech3+/Advanced Strongholds2+. Original Duke/assassination/Terror, chosen-plan Advanced Occupy and separate physical armies remain. Ecaz-led Suk rescues only its own fixed casualties; Diplomat uses the selected faction's own undialed commitment. Owner-pool skill composition is an explicit implementation inference, not a combined-skill FAQ. E3 pairs/Harkonnen Advanced/HW/previews/shared-Duke training/enhanced relocation and pending rulings remain guarded. | [Source and verified scope](NEXUS_CARD_RULES.md#standalone-e3-nexus-skills-and-original-components--5-october-2026), [coalition contract](ECAZ_OCCUPY_RULES.md#standalone-nexus--skills-coalition--5-october-2026) |
|
||||||
| Harkonnen special-Karama forced return | Return automatically only when the combined hand equals the count owed. Keep actual drawn-card observations owner-private after settlement; legacy forced saves describe held-card history without inventing draw provenance. Existing provisional auction-payment recovery and mode gates remain. | [Contract and limits](HARKONNEN_EXCHANGE.md) |
|
| Harkonnen special-Karama forced return | Return automatically only when the combined hand equals the count owed. Keep actual drawn-card observations owner-private after settlement; legacy forced saves describe held-card history without inventing draw provenance. Existing provisional auction-payment recovery and mode gates remain. | [Contract and limits](HARKONNEN_EXCHANGE.md) |
|
||||||
| Basic Moritani / Leader Skills | Ordinary Terror death returns the assigned skill once; own revival offers the existing private optional replacement. Sabotage does not take skills, alliances do not transfer assignments, and mandatory Planetologist/Diplomat disposal excludes those cards from ally retention. This is composition of existing contracts, not a new ruling or resolution of unfinished skill effects. Advanced and other roster/module combinations remain gated. | [Integration contract](MORITANI_LEADER_SKILLS.md) |
|
| Basic Moritani / Leader Skills | Ordinary Terror death returns the assigned skill once; own revival offers the existing private optional replacement. Sabotage does not take skills, alliances do not transfer assignments, and mandatory Planetologist/Diplomat disposal excludes those cards from ally retention. This is composition of existing contracts, not a new ruling or resolution of unfinished skill effects. Advanced and other roster/module combinations remain gated. | [Integration contract](MORITANI_LEADER_SKILLS.md) |
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ const decisions = {
|
|||||||
homeworldOccupiedIncome: true,
|
homeworldOccupiedIncome: true,
|
||||||
homeworldOccupiedPercentage: true,
|
homeworldOccupiedPercentage: true,
|
||||||
homeworldOccupiedBonus: true,
|
homeworldOccupiedBonus: true,
|
||||||
|
homeworldTupileCleanup: true,
|
||||||
caladanReinforcement: true,
|
caladanReinforcement: true,
|
||||||
choamAudit: true,
|
choamAudit: true,
|
||||||
nexusChoamInspection: true,
|
nexusChoamInspection: true,
|
||||||
|
|||||||
@@ -1132,11 +1132,37 @@ function saphoAction(g: GameView): Action | null {
|
|||||||
options.find((candidate) => candidate.mode === preferred) ?? options[0];
|
options.find((candidate) => candidate.mode === preferred) ?? options[0];
|
||||||
return { type: 'card', card: card.id, ...option };
|
return { type: 'card', card: card.id, ...option };
|
||||||
}
|
}
|
||||||
|
function tupileCleanupActions(g: GameView): Action[] {
|
||||||
|
const me = g.players.find((player) => player.id === g.me);
|
||||||
|
const offer = g.homeworldTupileCleanup;
|
||||||
|
if (!me || !(me.bot ?? me.autopilot) || g.status !== 'playing' ||
|
||||||
|
g.roomControl?.paused || g.roomControl?.closed || g.automaticContinuationPending ||
|
||||||
|
g.response || g.truthtrance || !offer || offer.blocked ||
|
||||||
|
offer.player !== g.me || g.decision?.kind !== 'homeworldTupileCleanup' ||
|
||||||
|
g.decision.player !== g.me || g.decision.event !== offer.event ||
|
||||||
|
!Number.isSafeInteger(offer.excess) || offer.excess <= 0)
|
||||||
|
return [];
|
||||||
|
const eligible = new Set(offer.eligibleCards);
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const cards = (me.hand ?? []).filter((card) => {
|
||||||
|
if (!eligible.has(card.id) || seen.has(card.id)) return false;
|
||||||
|
seen.add(card.id);
|
||||||
|
return true;
|
||||||
|
}).sort((a, b) => technologyCardValue(g, a) - technologyCardValue(g, b) ||
|
||||||
|
a.id.localeCompare(b.id));
|
||||||
|
if (cards.length < offer.excess) return [];
|
||||||
|
return [{ type: 'decision', event: offer.event,
|
||||||
|
cards: cards.slice(0, offer.excess).map((card) => card.id) }];
|
||||||
|
}
|
||||||
|
|
||||||
function policyActions(g: GameView): Action[] {
|
function policyActions(g: GameView): Action[] {
|
||||||
const me = g.players.find((p) => p.id === g.me)!;
|
const me = g.players.find((p) => p.id === g.me)!;
|
||||||
const level = rank(g);
|
const level = rank(g);
|
||||||
if (!(me.bot ?? me.autopilot) || g.status === 'finished') return [];
|
if (!(me.bot ?? me.autopilot) || g.status === 'finished') return [];
|
||||||
if (g.status === 'lobby') return me.ready ? [] : [{ type: 'ready' }];
|
if (g.status === 'lobby') return me.ready ? [] : [{ type: 'ready' }];
|
||||||
|
if (g.homeworldTupileBlocked) return [];
|
||||||
|
if (g.decision?.kind === 'homeworldTupileCleanup' || g.homeworldTupileCleanup)
|
||||||
|
return tupileCleanupActions(g);
|
||||||
if (!g.decision && !g.response && g.richeseBidding?.stage === 'cacheOffer' && g.richeseBidding.offerBlocked)
|
if (!g.decision && !g.response && g.richeseBidding?.stage === 'cacheOffer' && g.richeseBidding.offerBlocked)
|
||||||
return [];
|
return [];
|
||||||
// Resolve these original source choices before unrelated optional card plays.
|
// Resolve these original source choices before unrelated optional card plays.
|
||||||
@@ -3886,6 +3912,9 @@ function standaloneGholaAction(g: GameView, ordinary: Action[]): Action | null {
|
|||||||
|
|
||||||
/** Obligations apply across policy branches, including choosing to move first. */
|
/** Obligations apply across policy branches, including choosing to move first. */
|
||||||
export function botActions(g: GameView): Action[] {
|
export function botActions(g: GameView): Action[] {
|
||||||
|
if (g.homeworldTupileBlocked) return [];
|
||||||
|
if (g.decision?.kind === 'homeworldTupileCleanup' || g.homeworldTupileCleanup)
|
||||||
|
return tupileCleanupActions(g);
|
||||||
if (g.nexusHarkonnenBetrayalReaction) {
|
if (g.nexusHarkonnenBetrayalReaction) {
|
||||||
if (g.status !== 'playing' || g.roomControl?.paused || g.roomControl?.closed ||
|
if (g.status !== 'playing' || g.roomControl?.paused || g.roomControl?.closed ||
|
||||||
g.automaticContinuationPending) return [];
|
g.automaticContinuationPending) return [];
|
||||||
|
|||||||
+185
-5
@@ -98,6 +98,7 @@ import { beginHomeworldOccupiedIncome, quoteHomeworldOccupiedIncomeChoice, homew
|
|||||||
import { createOccupiedPercentageState, quoteOccupiedPercentageSource, allocateOccupiedPercentageReceipt, validateOccupiedPercentageState, type OccupiedPercentageState, type OccupiedPercentageSource } from './homeworld-occupied-percentage';
|
import { createOccupiedPercentageState, quoteOccupiedPercentageSource, allocateOccupiedPercentageReceipt, validateOccupiedPercentageState, type OccupiedPercentageState, type OccupiedPercentageSource } from './homeworld-occupied-percentage';
|
||||||
import { quoteOccupiedBiddingAuthority, requireOccupiedBiddingController, requireOccupiedBiddingBonusRecipient } from './homeworld-occupied-bidding';
|
import { quoteOccupiedBiddingAuthority, requireOccupiedBiddingController, requireOccupiedBiddingBonusRecipient } from './homeworld-occupied-bidding';
|
||||||
import { quoteStableHomeworldOccupation } from './homeworld-stable-occupation';
|
import { quoteStableHomeworldOccupation } from './homeworld-stable-occupation';
|
||||||
|
import { createOccupiedTupileState, syncOccupiedTupileLease, quoteOccupiedTupileAuthority, quoteOccupiedTupileCleanup, completeOccupiedTupileCleanup, validateOccupiedTupileState, type OccupiedTupileState } from './homeworld-occupied-tupile';
|
||||||
import { quoteGrummanCollection, quoteGrummanCollectionAction } from './grumman-collection';
|
import { quoteGrummanCollection, quoteGrummanCollectionAction } from './grumman-collection';
|
||||||
import { grummanCollectionSignature, validateGrummanCollection, type GrummanCollection } from './grumman-collection-return';
|
import { grummanCollectionSignature, validateGrummanCollection, type GrummanCollection } from './grumman-collection-return';
|
||||||
import { ambassadorPhaseAllowed } from './ambassador-phase';
|
import { ambassadorPhaseAllowed } from './ambassador-phase';
|
||||||
@@ -615,6 +616,8 @@ export type Player = {
|
|||||||
fremenNexusWormBlockedTurn?: number;
|
fremenNexusWormBlockedTurn?: number;
|
||||||
/** Permanent printed one-card reduction after Atomics; bound to its Aftermath receipt. */
|
/** Permanent printed one-card reduction after Atomics; bound to its Aftermath receipt. */
|
||||||
atomicsHandLimitPenalty?: boolean;
|
atomicsHandLimitPenalty?: boolean;
|
||||||
|
/** Derived occupied Tupile slot, backed by the original fresh lease ledger. */
|
||||||
|
tupileHandSlot?: true;
|
||||||
elites?: {
|
elites?: {
|
||||||
reserves: number;
|
reserves: number;
|
||||||
tanks: number;
|
tanks: number;
|
||||||
@@ -782,6 +785,7 @@ export type Decision =
|
|||||||
| { kind: 'homeworldOccupiedIncome'; player: string; event: string; world: string }
|
| { kind: 'homeworldOccupiedIncome'; player: string; event: string; world: string }
|
||||||
| { kind: 'homeworldOccupiedPercentage'; player: string; event: string }
|
| { kind: 'homeworldOccupiedPercentage'; player: string; event: string }
|
||||||
| { kind: 'homeworldOccupiedBonus'; player: string; event: string }
|
| { kind: 'homeworldOccupiedBonus'; player: string; event: string }
|
||||||
|
| { kind: 'homeworldTupileCleanup'; player: string; event: string }
|
||||||
| { kind: 'caladanReinforcement'; player: string; event: string }
|
| { kind: 'caladanReinforcement'; player: string; event: string }
|
||||||
| { kind: 'choamAudit'; player: string; event: string }
|
| { kind: 'choamAudit'; player: string; event: string }
|
||||||
| { kind: 'nexusChoamInspection'; player: string; event: string }
|
| { kind: 'nexusChoamInspection'; player: string; event: string }
|
||||||
@@ -1889,6 +1893,16 @@ export type Game = {
|
|||||||
homeworldOccupationHistory?: HomeworldOccupationHistory;
|
homeworldOccupationHistory?: HomeworldOccupationHistory;
|
||||||
/** Fresh development occupation entry only; never inferred for an older Homeworld save. */
|
/** Fresh development occupation entry only; never inferred for an older Homeworld save. */
|
||||||
homeworldOccupationPreview?: true;
|
homeworldOccupationPreview?: true;
|
||||||
|
/** Fresh Tupile capacity entry only; older occupation games are not converted. */
|
||||||
|
homeworldTupilePreview?: true;
|
||||||
|
homeworldTupileState?: OccupiedTupileState;
|
||||||
|
pendingHomeworldTupileCleanup?: {
|
||||||
|
event: string;
|
||||||
|
player: string;
|
||||||
|
hand: string[];
|
||||||
|
opening: Game['phaseOpening'];
|
||||||
|
signature: string;
|
||||||
|
} | null;
|
||||||
homeworldAuctionInspection?: { lot: string; audience: string[]; native: string | null; occupation: string; occupiedKnown: boolean };
|
homeworldAuctionInspection?: { lot: string; audience: string[]; native: string | null; occupation: string; occupiedKnown: boolean };
|
||||||
pendingHomeworldOccupiedIncome?: HomeworldOccupiedIncomeState | null;
|
pendingHomeworldOccupiedIncome?: HomeworldOccupiedIncomeState | null;
|
||||||
homeworldOccupiedIncomeHistory?: HomeworldOccupiedIncomeState[];
|
homeworldOccupiedIncomeHistory?: HomeworldOccupiedIncomeState[];
|
||||||
@@ -4565,11 +4579,14 @@ function richeseGiftView(g: Game, viewer: Player) {
|
|||||||
: null,
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
export const handLimit = (
|
export const nativeHandLimit = (
|
||||||
p: Pick<Player, 'faction'> & Partial<Pick<Player, 'atomicsHandLimitPenalty'>>,
|
p: Pick<Player, 'faction'> & Partial<Pick<Player, 'atomicsHandLimitPenalty'>>,
|
||||||
) =>
|
) =>
|
||||||
(p.faction === 'harkonnen' ? 8 : p.faction === 'choam' ? 5 : 4) -
|
(p.faction === 'harkonnen' ? 8 : p.faction === 'choam' ? 5 : 4) -
|
||||||
Number(p.atomicsHandLimitPenalty === true);
|
Number(p.atomicsHandLimitPenalty === true);
|
||||||
|
export const handLimit = (
|
||||||
|
p: Pick<Player, 'faction'> & Partial<Pick<Player, 'atomicsHandLimitPenalty' | 'tupileHandSlot'>>,
|
||||||
|
) => nativeHandLimit(p) + Number(p.tupileHandSlot === true);
|
||||||
|
|
||||||
function atomicsRule<T>(run: () => T): T {
|
function atomicsRule<T>(run: () => T): T {
|
||||||
try { return run(); }
|
try { return run(); }
|
||||||
@@ -7494,7 +7511,7 @@ function requireFreshSetup(g: Game, allowIxElites = false) {
|
|||||||
p.advisors === undefined && p.gholaBlocked === undefined && p.ixMovementBlocked === undefined &&
|
p.advisors === undefined && p.gholaBlocked === undefined && p.ixMovementBlocked === undefined &&
|
||||||
p.fremenMovementBlocked === undefined && p.fremenNexusMovementBlockedTurn === undefined &&
|
p.fremenMovementBlocked === undefined && p.fremenNexusMovementBlockedTurn === undefined &&
|
||||||
p.fremenNexusWormBlockedTurn === undefined &&
|
p.fremenNexusWormBlockedTurn === undefined &&
|
||||||
p.atomicsHandLimitPenalty === undefined && p.faceDancers === undefined &&
|
p.atomicsHandLimitPenalty === undefined && p.tupileHandSlot === undefined && p.faceDancers === undefined &&
|
||||||
p.faceDancerReplacedTurn === undefined && p.revealedTraitors === undefined;
|
p.faceDancerReplacedTurn === undefined && p.revealedTraitors === undefined;
|
||||||
}), 'Starting setup requires unused native leaders and no prior alliance, revival or battle history.');
|
}), 'Starting setup requires unused native leaders and no prior alliance, revival or battle history.');
|
||||||
requireRule(Object.keys(g.playerPositions ?? {}).every(id => g.players.some(p => p.id === id)),
|
requireRule(Object.keys(g.playerPositions ?? {}).every(id => g.players.some(p => p.id === id)),
|
||||||
@@ -8526,6 +8543,11 @@ export function initializeHomeworldOccupationGameForAudit(state: Game): Game {
|
|||||||
g.homeworldOccupiedPercentageLedger = createOccupiedPercentageState();
|
g.homeworldOccupiedPercentageLedger = createOccupiedPercentageState();
|
||||||
g.pendingHomeworldOccupiedPercentage = null;
|
g.pendingHomeworldOccupiedPercentage = null;
|
||||||
g.pendingHomeworldOccupiedBonus = null;
|
g.pendingHomeworldOccupiedBonus = null;
|
||||||
|
if (byFaction(g, 'choam')) {
|
||||||
|
g.homeworldTupilePreview = true;
|
||||||
|
g.homeworldTupileState = createOccupiedTupileState();
|
||||||
|
g.pendingHomeworldTupileCleanup = null;
|
||||||
|
}
|
||||||
return g;
|
return g;
|
||||||
}
|
}
|
||||||
/** Offline-only seam for the independent Nexus module; public starts remain gated. */
|
/** Offline-only seam for the independent Nexus module; public starts remain gated. */
|
||||||
@@ -18317,6 +18339,7 @@ function observeOccupation(g: Game, cause: 'change' | 'turnStart' | 'turnEnd' =
|
|||||||
cause === 'change'
|
cause === 'change'
|
||||||
? `homeworld-change-${g.homeworldOccupationHistory!.sources[0].event}-${g.homeworldOccupationHistory!.sources.length}`
|
? `homeworld-change-${g.homeworldOccupationHistory!.sources[0].event}-${g.homeworldOccupationHistory!.sources.length}`
|
||||||
: `homeworld-turn-${g.turn}-${cause}`));
|
: `homeworld-turn-${g.turn}-${cause}`));
|
||||||
|
syncTupileSlots(g);
|
||||||
}
|
}
|
||||||
function homeworldHistoryIntegrity(g: Game) {
|
function homeworldHistoryIntegrity(g: Game) {
|
||||||
const initialized = g.homeworlds?.historyVersion === 1;
|
const initialized = g.homeworlds?.historyVersion === 1;
|
||||||
@@ -18330,6 +18353,7 @@ function homeworldHistoryIntegrity(g: Game) {
|
|||||||
} else requireRule(g.tupileIntelligence === undefined, 'Tupile intelligence requires its original Homeworld history.');
|
} else requireRule(g.tupileIntelligence === undefined, 'Tupile intelligence requires its original Homeworld history.');
|
||||||
homeworldOccupiedIncomeIntegrity(g);
|
homeworldOccupiedIncomeIntegrity(g);
|
||||||
occupiedPercentageIntegrity(g);
|
occupiedPercentageIntegrity(g);
|
||||||
|
tupileSlotsIntegrity(g);
|
||||||
}
|
}
|
||||||
function homeworldOccupiedIncomeIntegrity(g: Game) {
|
function homeworldOccupiedIncomeIntegrity(g: Game) {
|
||||||
const pending = g.pendingHomeworldOccupiedIncome, history = g.homeworldOccupiedIncomeHistory;
|
const pending = g.pendingHomeworldOccupiedIncome, history = g.homeworldOccupiedIncomeHistory;
|
||||||
@@ -18419,6 +18443,134 @@ function projectedHomeworldOccupiedIncome(g: Game, player: string) {
|
|||||||
if (g.decision?.kind !== 'homeworldOccupiedIncome' || !g.pendingHomeworldOccupiedIncome) return null;
|
if (g.decision?.kind !== 'homeworldOccupiedIncome' || !g.pendingHomeworldOccupiedIncome) return null;
|
||||||
return homeworldRule(() => homeworldOccupiedIncomeOffer(g.pendingHomeworldOccupiedIncome!, g, player));
|
return homeworldRule(() => homeworldOccupiedIncomeOffer(g.pendingHomeworldOccupiedIncome!, g, player));
|
||||||
}
|
}
|
||||||
|
function tupileSlotsIntegrity(g: Game, dispatch?: Extract<Decision, { kind: 'homeworldTupileCleanup' }>) {
|
||||||
|
const state = g.homeworldTupileState, pending = g.pendingHomeworldTupileCleanup;
|
||||||
|
if (!g.homeworldTupilePreview) {
|
||||||
|
requireRule(state === undefined && pending === undefined &&
|
||||||
|
g.players.every(player => player.tupileHandSlot === undefined),
|
||||||
|
'Occupied Tupile slots require their original fresh profile.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requireRule(g.homeworldOccupationPreview && state, 'The original Tupile lease ledger is missing.');
|
||||||
|
homeworldRule(() => validateOccupiedTupileState(state, g));
|
||||||
|
const lease = state.current === null ? null : state.leases[state.current];
|
||||||
|
for (const player of g.players) {
|
||||||
|
const backed = !!lease?.holders.includes(player.id) &&
|
||||||
|
!state.cleanups.some(row => row.player === player.id && row.status === 'pending');
|
||||||
|
requireRule(player.tupileHandSlot === (backed ? true : undefined),
|
||||||
|
'The hand slot lost its original occupied Tupile grant.');
|
||||||
|
}
|
||||||
|
if (pending) {
|
||||||
|
const row = state.cleanups.find(row => row.event === pending.event);
|
||||||
|
const decision = g.decision ?? dispatch;
|
||||||
|
requireRule(row?.status === 'pending' && row.player === pending.player &&
|
||||||
|
pending.signature === JSON.stringify([pending.event, pending.player, pending.hand, pending.opening, g.turn, g.phase]) &&
|
||||||
|
JSON.stringify(pending.hand) === JSON.stringify(getPlayer(g, pending.player).hand.map(card => card.id)) &&
|
||||||
|
decision?.kind === 'homeworldTupileCleanup' && decision.player === pending.player &&
|
||||||
|
decision.event === pending.event && !g.response && !g.phaseOpening,
|
||||||
|
'The original Tupile hand cleanup or suspended opening changed.');
|
||||||
|
} else requireRule(g.decision?.kind !== 'homeworldTupileCleanup', 'The Tupile cleanup lost its original source.');
|
||||||
|
}
|
||||||
|
function syncTupileSlots(g: Game) {
|
||||||
|
if (!g.homeworldTupilePreview || g.status !== 'playing' || !g.homeworlds?.custody) return;
|
||||||
|
const result = homeworldRule(() => syncOccupiedTupileLease(g, g.homeworldTupileState!));
|
||||||
|
if (result.authority.status === 'unknown') return;
|
||||||
|
g.homeworldTupileState = result.state;
|
||||||
|
for (const player of g.players) {
|
||||||
|
if (result.slotHolders.includes(player.id)) player.tupileHandSlot = true;
|
||||||
|
else delete player.tupileHandSlot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function tupileSourceBlock(g: Game): string | null {
|
||||||
|
return g.homeworldTupilePreview && g.status === 'playing' && g.homeworlds?.custody
|
||||||
|
? homeworldRule(() => quoteOccupiedTupileAuthority(g)).blocked : null;
|
||||||
|
}
|
||||||
|
function tupileCleanupBoundary(g: Game): boolean {
|
||||||
|
return g.status === 'playing' && !g.decision && !g.response && !g.truthtrance &&
|
||||||
|
!g.pendingKarama && !g.pendingTreacheryDiscard && !g.pendingNullentropy &&
|
||||||
|
!g.pendingExchange && !g.pendingRicheseGift && !g.pendingRichesePurchaseIncome && !g.battle;
|
||||||
|
}
|
||||||
|
function pendingTupileCleanup(g: Game) {
|
||||||
|
const rows = g.homeworldTupileState?.cleanups;
|
||||||
|
if (!rows) return null;
|
||||||
|
for (const player of g.order) {
|
||||||
|
const row = rows.find(row => row.player === player && row.status === 'pending');
|
||||||
|
if (row) return row;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function prepareTupileCleanup(g: Game) {
|
||||||
|
if (!g.homeworldTupilePreview || g.pendingHomeworldTupileCleanup ||
|
||||||
|
!tupileCleanupBoundary(g) || tupileSourceBlock(g)) return;
|
||||||
|
for (;;) {
|
||||||
|
const row = pendingTupileCleanup(g);
|
||||||
|
if (!row) return;
|
||||||
|
const quote = homeworldRule(() => quoteOccupiedTupileCleanup(g, g.homeworldTupileState!, row.event));
|
||||||
|
if (quote.blocked) return;
|
||||||
|
if (!quote.excess) {
|
||||||
|
g.homeworldTupileState = homeworldRule(() => completeOccupiedTupileCleanup(g, g.homeworldTupileState!, row.event, row.player));
|
||||||
|
syncTupileSlots(g);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const hand = getPlayer(g, row.player).hand.map(card => card.id), opening = g.phaseOpening ?? null;
|
||||||
|
g.pendingHomeworldTupileCleanup = { event: row.event, player: row.player, hand, opening,
|
||||||
|
signature: JSON.stringify([row.event, row.player, hand, opening, g.turn, g.phase]) };
|
||||||
|
g.phaseOpening = null;
|
||||||
|
g.decision = { kind: 'homeworldTupileCleanup', player: row.player, event: row.event };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function tupileCleanupCards(g: Game, player: Player): string[] {
|
||||||
|
return player.hand.filter(card => !transferCardBlock(g, player, card) &&
|
||||||
|
!giftReserved(g, player.id, card.id) && !retentionReservesCard(g, player.id, card.id))
|
||||||
|
.map(card => card.id);
|
||||||
|
}
|
||||||
|
function projectedTupileCleanup(g: Game, viewer: string) {
|
||||||
|
const pending = g.pendingHomeworldTupileCleanup;
|
||||||
|
if (!pending || pending.player !== viewer) return null;
|
||||||
|
tupileSlotsIntegrity(g);
|
||||||
|
const quote = homeworldRule(() => quoteOccupiedTupileCleanup(g, g.homeworldTupileState!, pending.event));
|
||||||
|
const player = getPlayer(g, pending.player), eligibleCards = tupileCleanupCards(g, player);
|
||||||
|
return { event: pending.event, player: player.id, playerName: player.name,
|
||||||
|
limit: quote.limit, excess: quote.excess, eligibleCards,
|
||||||
|
blocked: quote.blocked ?? (eligibleCards.length < quote.excess
|
||||||
|
? 'The original committed cards must settle before this hand cleanup can complete.' : null) };
|
||||||
|
}
|
||||||
|
function decideTupileCleanup(g: Game, player: Player,
|
||||||
|
decision: Extract<Decision, { kind: 'homeworldTupileCleanup' }>, action: Action) {
|
||||||
|
tupileSlotsIntegrity(g, decision);
|
||||||
|
const pending = g.pendingHomeworldTupileCleanup!;
|
||||||
|
requireRule(pending && player.id === pending.player && action.event === pending.event &&
|
||||||
|
Object.keys(action).sort().join(',') === 'cards,event,type' && Array.isArray(action.cards) &&
|
||||||
|
action.cards.every(id => typeof id === 'string') && new Set(action.cards).size === action.cards.length,
|
||||||
|
'Choose the distinct original held cards for this Tupile cleanup.');
|
||||||
|
const quote = homeworldRule(() => quoteOccupiedTupileCleanup(g, g.homeworldTupileState!, pending.event));
|
||||||
|
const selected = action.cards as string[], eligible = tupileCleanupCards(g, player), physical = physicalTreacheryCards(g);
|
||||||
|
requireRule(!quote.blocked && selected.length === quote.excess &&
|
||||||
|
selected.every(id => eligible.includes(id) && physical.filter(card => card.id === id).length === 1),
|
||||||
|
'Discard exactly the normal-limit excess without consuming a reserved card.');
|
||||||
|
for (const id of selected) discard(g, player, id);
|
||||||
|
g.homeworldTupileState = homeworldRule(() => completeOccupiedTupileCleanup(g, g.homeworldTupileState!, pending.event, player.id));
|
||||||
|
g.pendingHomeworldTupileCleanup = null;
|
||||||
|
g.decision = null;
|
||||||
|
g.phaseOpening = pending.opening;
|
||||||
|
log(g, `${player.name} discarded ${selected.length} Treachery Card${selected.length === 1 ? '' : 's'} to the normal hand limit after losing the original occupied Tupile slot.`,
|
||||||
|
{ faction: player.faction, name: 'Tupile hand cleanup' });
|
||||||
|
syncTupileSlots(g);
|
||||||
|
prepareTupileCleanup(g);
|
||||||
|
}
|
||||||
|
function tupileAutomatic(g: Game): boolean {
|
||||||
|
if (!g.homeworldTupilePreview || g.pendingHomeworldTupileCleanup ||
|
||||||
|
g.status !== 'playing' || !g.homeworlds?.custody || tupileSourceBlock(g)) return false;
|
||||||
|
const result = homeworldRule(() => syncOccupiedTupileLease(g, g.homeworldTupileState!));
|
||||||
|
return result.state.signature !== g.homeworldTupileState!.signature ||
|
||||||
|
!!(result.pending.length && tupileCleanupBoundary(g));
|
||||||
|
}
|
||||||
|
function currentTupileOccupation(g: Game) {
|
||||||
|
return g.homeworldTupilePreview
|
||||||
|
? homeworldRule(() => quoteOccupiedTupileAuthority(g)).status
|
||||||
|
: tupileOccupationStatus(g.homeworldOccupationHistory, homeworldContext(g));
|
||||||
|
}
|
||||||
function tupileIntelligenceBlock(g: Game): string | null {
|
function tupileIntelligenceBlock(g: Game): string | null {
|
||||||
if (!g.homeworlds?.custody || !g.tupileIntelligence || !g.homeworldOccupationHistory)
|
if (!g.homeworlds?.custody || !g.tupileIntelligence || !g.homeworldOccupationHistory)
|
||||||
return 'This saved game lacks the original occupation and intelligence history required for Tupile.';
|
return 'This saved game lacks the original occupation and intelligence history required for Tupile.';
|
||||||
@@ -18435,7 +18587,7 @@ function projectedTupileIntelligence(g: Game, id: string) {
|
|||||||
const state = g.tupileIntelligence;
|
const state = g.tupileIntelligence;
|
||||||
const targets = homeworldRule(() => tupileIntelligenceTargets(homeworldContext(g), g.homeworlds!.custody!, id,
|
const targets = homeworldRule(() => tupileIntelligenceTargets(homeworldContext(g), g.homeworlds!.custody!, id,
|
||||||
state?.receipts.map((receipt) => receipt.faction) ?? [],
|
state?.receipts.map((receipt) => receipt.faction) ?? [],
|
||||||
tupileOccupationStatus(g.homeworldOccupationHistory, homeworldContext(g))));
|
currentTupileOccupation(g)));
|
||||||
return {owner: id, blocked: tupileIntelligenceBlock(g), targets,
|
return {owner: id, blocked: tupileIntelligenceBlock(g), targets,
|
||||||
receipts: (state?.receipts ?? []).map(({event, target, faction, category, spice, count, turn, phase}) =>
|
receipts: (state?.receipts ?? []).map(({event, target, faction, category, spice, count, turn, phase}) =>
|
||||||
({event, target, faction, category, spice, count, turn, phase}))};
|
({event, target, faction, category, spice, count, turn, phase}))};
|
||||||
@@ -18449,7 +18601,7 @@ function requestTupileIntelligence(g: Game, p: Player, action: Action) {
|
|||||||
'Choose one opposing faction and either weapons or defenses.');
|
'Choose one opposing faction and either weapons or defenses.');
|
||||||
const request = homeworldRule(() => quoteTupileIntelligenceRequest(homeworldContext(g), g.homeworlds!.custody!, p.id,
|
const request = homeworldRule(() => quoteTupileIntelligenceRequest(homeworldContext(g), g.homeworlds!.custody!, p.id,
|
||||||
g.tupileIntelligence!.receipts.map((receipt) => receipt.faction),
|
g.tupileIntelligence!.receipts.map((receipt) => receipt.faction),
|
||||||
tupileOccupationStatus(g.homeworldOccupationHistory, homeworldContext(g)), action.target as string,
|
currentTupileOccupation(g), action.target as string,
|
||||||
action.category as TupileIntelligenceCategory));
|
action.category as TupileIntelligenceCategory));
|
||||||
const target = getPlayer(g, request.target);
|
const target = getPlayer(g, request.target);
|
||||||
const answer = homeworldRule(() => quoteTupileIntelligenceAnswer(target.hand, target.spice, request.category));
|
const answer = homeworldRule(() => quoteTupileIntelligenceAnswer(target.hand, target.spice, request.category));
|
||||||
@@ -27141,6 +27293,9 @@ function settleAutomaticContinuations(g: Game) {
|
|||||||
if (g.pendingGuildBetrayal) return;
|
if (g.pendingGuildBetrayal) return;
|
||||||
if (g.pendingNexusIxianReplacement) return;
|
if (g.pendingNexusIxianReplacement) return;
|
||||||
if (g.pendingRicheseBetrayal) return;
|
if (g.pendingRicheseBetrayal) return;
|
||||||
|
syncTupileSlots(g);
|
||||||
|
prepareTupileCleanup(g);
|
||||||
|
if (g.pendingHomeworldTupileCleanup || tupileSourceBlock(g)) return;
|
||||||
if (g.truthtrance || g.phaseOpening || g.status === 'finished') return;
|
if (g.truthtrance || g.phaseOpening || g.status === 'finished') return;
|
||||||
const response = g.response;
|
const response = g.response;
|
||||||
const before = JSON.stringify(g);
|
const before = JSON.stringify(g);
|
||||||
@@ -27240,6 +27395,9 @@ export function normalizeAutomaticGame(state: Game): Game {
|
|||||||
ecazCollectionIntegrity(state);
|
ecazCollectionIntegrity(state);
|
||||||
ecazAllianceIntegrity(state);
|
ecazAllianceIntegrity(state);
|
||||||
const g = structuredClone(state);
|
const g = structuredClone(state);
|
||||||
|
syncTupileSlots(g);
|
||||||
|
prepareTupileCleanup(g);
|
||||||
|
if (g.pendingHomeworldTupileCleanup || tupileSourceBlock(g)) return g;
|
||||||
if (g.pendingNexusHarkonnenBetrayal) return g;
|
if (g.pendingNexusHarkonnenBetrayal) return g;
|
||||||
if (g.pendingNexusIxianBetrayal) return g;
|
if (g.pendingNexusIxianBetrayal) return g;
|
||||||
if (g.pendingNexusIxianReplacement) return g;
|
if (g.pendingNexusIxianReplacement) return g;
|
||||||
@@ -27255,6 +27413,8 @@ export function normalizeAutomaticGame(state: Game): Game {
|
|||||||
normalizeCardNames(g);
|
normalizeCardNames(g);
|
||||||
normalizeBattle(g);
|
normalizeBattle(g);
|
||||||
finishActionContinuations(g);
|
finishActionContinuations(g);
|
||||||
|
prepareTupileCleanup(g);
|
||||||
|
if (g.pendingHomeworldTupileCleanup || tupileSourceBlock(g)) return g;
|
||||||
if (g.pendingTreacheryDiscard?.reaction) {
|
if (g.pendingTreacheryDiscard?.reaction) {
|
||||||
treacheryDiscardIntegrity(g);
|
treacheryDiscardIntegrity(g);
|
||||||
return g;
|
return g;
|
||||||
@@ -27274,6 +27434,7 @@ export function normalizeAutomaticGame(state: Game): Game {
|
|||||||
richesePairIntegrity(g);
|
richesePairIntegrity(g);
|
||||||
richeseBetrayalIntegrity(g);
|
richeseBetrayalIntegrity(g);
|
||||||
marketGholaIntegrity(g);
|
marketGholaIntegrity(g);
|
||||||
|
prepareTupileCleanup(g);
|
||||||
homeworldRule(() => homeworldGameIntegrity(g));
|
homeworldRule(() => homeworldGameIntegrity(g));
|
||||||
homeworldBattleLossIntegrity(g);
|
homeworldBattleLossIntegrity(g);
|
||||||
homeworldSubstitutionIntegrity(g);
|
homeworldSubstitutionIntegrity(g);
|
||||||
@@ -27294,6 +27455,18 @@ function applyActionInner(
|
|||||||
action: Action,
|
action: Action,
|
||||||
execution: 'live' | 'shipmentPreparation' = 'live',
|
execution: 'live' | 'shipmentPreparation' = 'live',
|
||||||
): Game {
|
): Game {
|
||||||
|
tupileSlotsIntegrity(state);
|
||||||
|
const tupileBlock = tupileSourceBlock(state);
|
||||||
|
requireRule(!tupileBlock, tupileBlock ?? 'The occupied Tupile source is unresolved.');
|
||||||
|
if (state.homeworldTupilePreview && state.status === 'playing' && action?.type !== 'advanceBots') {
|
||||||
|
const current = homeworldRule(() => syncOccupiedTupileLease(state, state.homeworldTupileState!));
|
||||||
|
requireRule(current.state.signature === state.homeworldTupileState!.signature,
|
||||||
|
'Resume the original occupied Tupile source before another action.');
|
||||||
|
}
|
||||||
|
requireRule(!state.pendingHomeworldTupileCleanup ||
|
||||||
|
action?.type === 'advanceBots' || action?.type === 'decision' &&
|
||||||
|
id === state.pendingHomeworldTupileCleanup.player && action.event === state.pendingHomeworldTupileCleanup.event,
|
||||||
|
'Finish the original occupied Tupile hand cleanup before another action.');
|
||||||
ecazTreacheryIntegrity(state);
|
ecazTreacheryIntegrity(state);
|
||||||
const g = structuredClone(state);
|
const g = structuredClone(state);
|
||||||
if (g.pendingNexusHarkonnenBetrayal) {
|
if (g.pendingNexusHarkonnenBetrayal) {
|
||||||
@@ -27976,6 +28149,10 @@ function applyActionInner(
|
|||||||
decideHomeworldOccupiedIncome(g, p, decision, action);
|
decideHomeworldOccupiedIncome(g, p, decision, action);
|
||||||
return g;
|
return g;
|
||||||
}
|
}
|
||||||
|
if (decision.kind === 'homeworldTupileCleanup') {
|
||||||
|
decideTupileCleanup(g, p, decision, action);
|
||||||
|
return g;
|
||||||
|
}
|
||||||
if (decision.kind === 'moritaniTerror') {
|
if (decision.kind === 'moritaniTerror') {
|
||||||
decideTerror(g, p, action);
|
decideTerror(g, p, action);
|
||||||
return g;
|
return g;
|
||||||
@@ -31366,7 +31543,7 @@ export function viewGame(state: Game, id: string) {
|
|||||||
schema: g.schema,
|
schema: g.schema,
|
||||||
botsPending: g.botsPending ?? false,
|
botsPending: g.botsPending ?? false,
|
||||||
semutaReaction: projectedSemutaReaction(g, me),
|
semutaReaction: projectedSemutaReaction(g, me),
|
||||||
automaticContinuationPending: (!!g.pendingTreacheryDiscard && !g.pendingTreacheryDiscard.reaction) || homeworldRevealPending(g) || homeworldShipmentAutomatic(g) || homeworldOccupiedIncomeAutomatic(g) || grummanCollectionAutomatic(g),
|
automaticContinuationPending: (!!g.pendingTreacheryDiscard && !g.pendingTreacheryDiscard.reaction) || homeworldRevealPending(g) || homeworldShipmentAutomatic(g) || homeworldOccupiedIncomeAutomatic(g) || grummanCollectionAutomatic(g) || tupileAutomatic(g),
|
||||||
botNextActionAt: g.botNextActionAt ?? null,
|
botNextActionAt: g.botNextActionAt ?? null,
|
||||||
code: g.code,
|
code: g.code,
|
||||||
version: g.version,
|
version: g.version,
|
||||||
@@ -31378,6 +31555,7 @@ export function viewGame(state: Game, id: string) {
|
|||||||
bureaucrat: projectedBureaucrat(g, id),
|
bureaucrat: projectedBureaucrat(g, id),
|
||||||
spiceBankerIncomePreview: g.spiceBankerIncomePreview === true,
|
spiceBankerIncomePreview: g.spiceBankerIncomePreview === true,
|
||||||
homeworldOccupationPreview: g.homeworldOccupationPreview === true,
|
homeworldOccupationPreview: g.homeworldOccupationPreview === true,
|
||||||
|
homeworldTupilePreview: g.homeworldTupilePreview === true,
|
||||||
spiceBankerIncome: g.spiceBankerIncomePreview
|
spiceBankerIncome: g.spiceBankerIncomePreview
|
||||||
? bankerIncomeRule(() => projectBankerIncome(g.spiceBankerIncome!, bankerIncomeContext(g))) : null,
|
? bankerIncomeRule(() => projectBankerIncome(g.spiceBankerIncome!, bankerIncomeContext(g))) : null,
|
||||||
bribeOptions: projectedBribes(g, me),
|
bribeOptions: projectedBribes(g, me),
|
||||||
@@ -31425,6 +31603,8 @@ export function viewGame(state: Game, id: string) {
|
|||||||
homeworldOccupiedIncome: projectedHomeworldOccupiedIncome(g, id),
|
homeworldOccupiedIncome: projectedHomeworldOccupiedIncome(g, id),
|
||||||
homeworldOccupiedPercentage: projectedOccupiedPercentage(g),
|
homeworldOccupiedPercentage: projectedOccupiedPercentage(g),
|
||||||
homeworldOccupiedBonus: projectedOccupiedBonus(g),
|
homeworldOccupiedBonus: projectedOccupiedBonus(g),
|
||||||
|
homeworldTupileCleanup: projectedTupileCleanup(g, id),
|
||||||
|
homeworldTupileBlocked: tupileSourceBlock(g),
|
||||||
tupileIntelligence: projectedTupileIntelligence(g, id),
|
tupileIntelligence: projectedTupileIntelligence(g, id),
|
||||||
terrorEntry: g.pendingTerrorEntry
|
terrorEntry: g.pendingTerrorEntry
|
||||||
? (() => {
|
? (() => {
|
||||||
|
|||||||
@@ -0,0 +1,288 @@
|
|||||||
|
import { nativeHandLimit } from './engine';
|
||||||
|
import { HomeworldCustodyError } from './homeworld-custody';
|
||||||
|
import { homeworldContext } from './homeworld-game';
|
||||||
|
import { validateHomeworldOccupationHistory } from './homeworld-occupation-history';
|
||||||
|
import {
|
||||||
|
quoteStableHomeworldOccupation,
|
||||||
|
type StableHomeworldOccupationContext,
|
||||||
|
} from './homeworld-stable-occupation';
|
||||||
|
|
||||||
|
export type OccupiedTupileContext = StableHomeworldOccupationContext & {
|
||||||
|
homeworldTupilePreview?: true;
|
||||||
|
};
|
||||||
|
export type OccupiedTupileSource = Readonly<{
|
||||||
|
world: 'homeworld:choam';
|
||||||
|
card: 'tupile';
|
||||||
|
native: string;
|
||||||
|
occupier: string;
|
||||||
|
qualification: string;
|
||||||
|
/** The original observation, not a current garrison or substitute provider. */
|
||||||
|
signature: string;
|
||||||
|
}>;
|
||||||
|
export type OccupiedTupileCapacity = Readonly<{
|
||||||
|
player: string;
|
||||||
|
normal: number;
|
||||||
|
occupied: number;
|
||||||
|
}>;
|
||||||
|
export type OccupiedTupileAuthority = Readonly<{
|
||||||
|
status: 'occupied' | 'unoccupied' | 'unknown';
|
||||||
|
provider: string | null;
|
||||||
|
source: OccupiedTupileSource | null;
|
||||||
|
holders: readonly string[];
|
||||||
|
capacities: readonly OccupiedTupileCapacity[];
|
||||||
|
/** This gates NEW low-threshold intelligence only, never historical knowledge
|
||||||
|
* or the original once-per-faction usage receipts. */
|
||||||
|
lowIntelligence: 'available' | 'suppressed' | 'unknown';
|
||||||
|
blocked: string | null;
|
||||||
|
}>;
|
||||||
|
export type OccupiedTupileLease = Readonly<{
|
||||||
|
id: number;
|
||||||
|
source: OccupiedTupileSource;
|
||||||
|
holders: readonly string[];
|
||||||
|
turn: number;
|
||||||
|
/** null while current; the immutable removed holders when this lease closes. */
|
||||||
|
lost: readonly string[] | null;
|
||||||
|
}>;
|
||||||
|
export type OccupiedTupileCleanup = Readonly<{
|
||||||
|
event: string;
|
||||||
|
lease: number;
|
||||||
|
player: string;
|
||||||
|
status: 'pending' | 'completed';
|
||||||
|
}>;
|
||||||
|
export type OccupiedTupileState = Readonly<{
|
||||||
|
version: 1;
|
||||||
|
leases: readonly OccupiedTupileLease[];
|
||||||
|
current: number | null;
|
||||||
|
cleanups: readonly OccupiedTupileCleanup[];
|
||||||
|
signature: string;
|
||||||
|
}>;
|
||||||
|
export type OccupiedTupileSync = Readonly<{
|
||||||
|
state: OccupiedTupileState;
|
||||||
|
authority: OccupiedTupileAuthority;
|
||||||
|
lease: OccupiedTupileLease | null;
|
||||||
|
/** Only these IDs may back Player.tupileHandSlot. A new lease cannot bypass
|
||||||
|
* an outstanding original normal-limit cleanup of the same player. */
|
||||||
|
slotHolders: readonly string[];
|
||||||
|
pending: readonly OccupiedTupileCleanup[];
|
||||||
|
}>;
|
||||||
|
export type OccupiedTupileCleanupQuote = Readonly<{
|
||||||
|
receipt: OccupiedTupileCleanup;
|
||||||
|
source: OccupiedTupileSource;
|
||||||
|
limit: number;
|
||||||
|
excess: number;
|
||||||
|
blocked: string | null;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
const text = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0;
|
||||||
|
const whole = (value: unknown): value is number => Number.isSafeInteger(value) && (value as number) >= 0;
|
||||||
|
function requireTupile(condition: unknown, message: string): asserts condition {
|
||||||
|
if (!condition) throw new HomeworldCustodyError(`Occupied Tupile: ${message}`);
|
||||||
|
}
|
||||||
|
function keys(value: unknown, expected: readonly string[]): boolean {
|
||||||
|
return !!value && typeof value === 'object' && !Array.isArray(value) &&
|
||||||
|
Object.keys(value).length === expected.length && expected.every(key => Object.hasOwn(value, key));
|
||||||
|
}
|
||||||
|
function sourceCopy(source: OccupiedTupileSource): OccupiedTupileSource {
|
||||||
|
return Object.freeze({ world: source.world, card: source.card, native: source.native,
|
||||||
|
occupier: source.occupier, qualification: source.qualification, signature: source.signature });
|
||||||
|
}
|
||||||
|
function sameSource(a: OccupiedTupileSource, b: OccupiedTupileSource): boolean {
|
||||||
|
return a.world === b.world && a.card === b.card && a.native === b.native && a.occupier === b.occupier &&
|
||||||
|
a.qualification === b.qualification && a.signature === b.signature;
|
||||||
|
}
|
||||||
|
const sameHolders = (a: readonly string[], b: readonly string[]) =>
|
||||||
|
a.length === b.length && a.every((id, index) => id === b[index]);
|
||||||
|
const cleanupEvent = (lease: OccupiedTupileLease, player: string) =>
|
||||||
|
JSON.stringify(['homeworldTupileCleanup', lease.id, lease.source.qualification, player]);
|
||||||
|
|
||||||
|
/** Printed original Tupile occupied face (homeworld-cards.ts): one slot EACH,
|
||||||
|
* never one per army, and loss of CHOAM's low advantage. Stable history owns all
|
||||||
|
* Basic ruling gates and the authorized Advanced retained-occupation lifecycle.
|
||||||
|
* The extra preview marker is original fresh-entry-only, not a public option. */
|
||||||
|
export function quoteOccupiedTupileAuthority(game: OccupiedTupileContext): OccupiedTupileAuthority {
|
||||||
|
const native = game.players.find(player => player.faction === 'choam');
|
||||||
|
const base = { provider: native?.id ?? null, source: null, holders: Object.freeze([]) as readonly string[],
|
||||||
|
capacities: Object.freeze([]) as readonly OccupiedTupileCapacity[] };
|
||||||
|
const none: OccupiedTupileAuthority = Object.freeze({ ...base, status: 'unoccupied',
|
||||||
|
lowIntelligence: 'available', blocked: null });
|
||||||
|
if (!game.homeworldTupilePreview || !native) return none;
|
||||||
|
if (!game.homeworldOccupationPreview) return Object.freeze({ ...base, status: 'unknown',
|
||||||
|
lowIntelligence: 'unknown', blocked: 'Tupile slots require the original fresh occupation profile.' });
|
||||||
|
const quote = quoteStableHomeworldOccupation(game, 'tupile');
|
||||||
|
const entitlement = quote.entitlement;
|
||||||
|
const blocked = quote.blocked ?? (entitlement?.ally === native.id
|
||||||
|
? 'The native CHOAM cannot ally with its own Homeworld occupier.' : null);
|
||||||
|
if (blocked) return Object.freeze({ ...base, status: 'unknown', lowIntelligence: 'unknown', blocked });
|
||||||
|
if (!entitlement) return none;
|
||||||
|
const original = game.homeworldOccupationHistory!.sources.find(source => source.event === entitlement.qualification);
|
||||||
|
requireTupile(original && original.cause !== 'setup', 'the original qualification source is missing.');
|
||||||
|
const source = sourceCopy({ world: 'homeworld:choam', card: 'tupile', native: native.id,
|
||||||
|
occupier: entitlement.occupier, qualification: entitlement.qualification, signature: original.signature });
|
||||||
|
const holders = Object.freeze([entitlement.occupier, ...(entitlement.ally ? [entitlement.ally] : [])]);
|
||||||
|
const capacities = Object.freeze(holders.map(id => {
|
||||||
|
const normal = nativeHandLimit(game.players.find(player => player.id === id)!);
|
||||||
|
return Object.freeze({ player: id, normal, occupied: normal + 1 });
|
||||||
|
}));
|
||||||
|
return Object.freeze({ provider: native.id, source, holders, capacities, status: 'occupied',
|
||||||
|
lowIntelligence: 'suppressed', blocked: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
function stateWith(leases: readonly OccupiedTupileLease[], current: number | null,
|
||||||
|
cleanups: readonly OccupiedTupileCleanup[]): OccupiedTupileState {
|
||||||
|
return Object.freeze({ version: 1, leases: Object.freeze(leases), current, cleanups: Object.freeze(cleanups),
|
||||||
|
signature: JSON.stringify([1, leases, current, cleanups]) });
|
||||||
|
}
|
||||||
|
export function createOccupiedTupileState(): OccupiedTupileState { return stateWith([], null, []); }
|
||||||
|
|
||||||
|
/** Original historical epochs, independently of today's force population. A
|
||||||
|
* retained Advanced epoch does not restart at native-high, contest or turn change.
|
||||||
|
* Sources remain in history after departure; no private knowledge is erased. */
|
||||||
|
function originalEpochs(game: OccupiedTupileContext): Map<string, OccupiedTupileSource> {
|
||||||
|
const history = game.homeworldOccupationHistory, native = game.players.find(player => player.faction === 'choam');
|
||||||
|
requireTupile(history && native && game.homeworlds?.historyVersion === 1,
|
||||||
|
'saved leases require their original fresh occupation history and native provider.');
|
||||||
|
validateHomeworldOccupationHistory(history, homeworldContext(game), game.turn);
|
||||||
|
const epochs = new Map<string, OccupiedTupileSource>();
|
||||||
|
let retained: string | null = null;
|
||||||
|
for (const observation of history.sources) {
|
||||||
|
const snapshot = JSON.parse(history.snapshots[observation.snapshot]) as [string, [string, number, number][]][];
|
||||||
|
const row = snapshot.find(([world]) => world === 'homeworld:choam');
|
||||||
|
requireTupile(row, 'the original Tupile world is missing from history.');
|
||||||
|
const present = row[1].filter(([, normal, elite]) => normal + elite > 0);
|
||||||
|
if (retained && !present.some(([id]) => id === retained)) retained = null;
|
||||||
|
const candidate = observation.cause !== 'setup' && present.length === 1 && present[0][0] !== native.id
|
||||||
|
? present[0][0] : null;
|
||||||
|
if (game.advanced ? !retained && candidate !== null : history.qualifications.some(fact =>
|
||||||
|
fact.world === 'homeworld:choam' && fact.event === observation.event)) {
|
||||||
|
const occupier = game.advanced ? candidate! : history.qualifications.find(fact =>
|
||||||
|
fact.world === 'homeworld:choam' && fact.event === observation.event)!.player;
|
||||||
|
epochs.set(observation.event, sourceCopy({ world: 'homeworld:choam', card: 'tupile', native: native.id,
|
||||||
|
occupier, qualification: observation.event, signature: observation.signature }));
|
||||||
|
if (game.advanced) retained = occupier;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return epochs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strict JSON ledger validation. Frozen ally grants cannot be rewritten to a
|
||||||
|
* current ally, and a closed original epoch cannot be resurrected as a new one. */
|
||||||
|
export function validateOccupiedTupileState(state: OccupiedTupileState, game: OccupiedTupileContext): void {
|
||||||
|
requireTupile(keys(state, ['version', 'leases', 'current', 'cleanups', 'signature']) && state.version === 1 &&
|
||||||
|
Array.isArray(state.leases) && Array.isArray(state.cleanups) && typeof state.signature === 'string' &&
|
||||||
|
(state.current === null || whole(state.current)), 'invalid persisted lease ledger.');
|
||||||
|
const epochs = state.leases.length > 0 ? originalEpochs(game) : null;
|
||||||
|
requireTupile(state.current === null || state.current === state.leases.length - 1, 'current lease must be the latest original grant.');
|
||||||
|
const expected: { event: string; lease: number; player: string }[] = [];
|
||||||
|
for (let index = 0; index < state.leases.length; index++) {
|
||||||
|
const lease = state.leases[index];
|
||||||
|
requireTupile(keys(lease, ['id', 'source', 'holders', 'turn', 'lost']) && lease.id === index &&
|
||||||
|
whole(lease.turn) && lease.turn > 0 && lease.turn <= game.turn && Array.isArray(lease.holders) &&
|
||||||
|
lease.holders.length >= 1 && lease.holders.length <= 2 && new Set(lease.holders).size === lease.holders.length &&
|
||||||
|
keys(lease.source, ['world', 'card', 'native', 'occupier', 'qualification', 'signature']) &&
|
||||||
|
text(lease.source.qualification) && text(lease.source.signature), 'invalid frozen original grant.');
|
||||||
|
const observation = game.homeworldOccupationHistory!.sources.find(row => row.event === lease.source.qualification);
|
||||||
|
requireTupile(observation && observation.turn <= lease.turn, 'grant predates its original qualification source.');
|
||||||
|
const original = epochs!.get(lease.source.qualification);
|
||||||
|
requireTupile(original && sameSource(original, lease.source) && lease.holders[0] === original.occupier &&
|
||||||
|
lease.holders.every((id: string) => game.players.some(player => player.id === id && player.id !== original.native)),
|
||||||
|
'grant lost its original source, provider or beneficiary.');
|
||||||
|
if (index > 0) {
|
||||||
|
const previous = state.leases[index - 1];
|
||||||
|
requireTupile(lease.turn >= previous.turn && !(sameSource(previous.source, lease.source) &&
|
||||||
|
sameHolders(previous.holders, lease.holders)), 'duplicate or out-of-order original grant.');
|
||||||
|
if (!sameSource(previous.source, lease.source)) {
|
||||||
|
const sources = game.homeworldOccupationHistory!.sources;
|
||||||
|
requireTupile(sources.findIndex(row => row.event === previous.source.qualification) <
|
||||||
|
sources.findIndex(row => row.event === lease.source.qualification) &&
|
||||||
|
!state.leases.slice(0, index).some(prior => sameSource(prior.source, lease.source)),
|
||||||
|
'closed or earlier original epoch was resurrected.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.current === index) requireTupile(lease.lost === null, 'current grant was already closed.');
|
||||||
|
else {
|
||||||
|
const next = state.leases[index + 1];
|
||||||
|
requireTupile(!next || keys(next, ['id', 'source', 'holders', 'turn', 'lost']) &&
|
||||||
|
keys(next.source, ['world', 'card', 'native', 'occupier', 'qualification', 'signature']) &&
|
||||||
|
Array.isArray(next.holders), 'invalid following original grant.');
|
||||||
|
const lost = next && sameSource(lease.source, next.source)
|
||||||
|
? lease.holders.filter((id: string) => !next.holders.includes(id)) : lease.holders;
|
||||||
|
requireTupile(Array.isArray(lease.lost) && sameHolders(lease.lost, lost), 'closed grant changed its original lost holders.');
|
||||||
|
for (const player of lost) expected.push({ event: cleanupEvent(lease, player), lease: index, player });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
requireTupile(state.cleanups.length === expected.length, 'cleanup epochs do not cover exactly the original lost grants.');
|
||||||
|
for (let index = 0; index < expected.length; index++) {
|
||||||
|
const row = state.cleanups[index], original = expected[index];
|
||||||
|
requireTupile(keys(row, ['event', 'lease', 'player', 'status']) && row.event === original.event &&
|
||||||
|
row.lease === original.lease && row.player === original.player &&
|
||||||
|
(row.status === 'pending' || row.status === 'completed'), 'invalid original cleanup receipt.');
|
||||||
|
}
|
||||||
|
requireTupile(state.signature === JSON.stringify([1, state.leases, state.current, state.cleanups]), 'saved lease ledger changed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncResult(state: OccupiedTupileState, authority: OccupiedTupileAuthority): OccupiedTupileSync {
|
||||||
|
const lease = state.current === null ? null : state.leases[state.current];
|
||||||
|
const pending = Object.freeze(state.cleanups.filter(row => row.status === 'pending'));
|
||||||
|
const slotHolders = Object.freeze(authority.status === 'occupied' && lease && authority.source &&
|
||||||
|
sameSource(lease.source, authority.source) && sameHolders(lease.holders, authority.holders)
|
||||||
|
? lease.holders.filter(id => !pending.some(row => row.player === id)) : []);
|
||||||
|
return Object.freeze({ state, authority, lease, slotHolders, pending });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist next state and backing flags atomically at the original occupation
|
||||||
|
* observation / reciprocal-alliance boundary. Repeated view/sync is idempotent.
|
||||||
|
* Unknown Basic expiry cannot invent departure, cleanup or a new beneficiary.
|
||||||
|
* No card/spice draw occurs here. Parent owns control-suffix suspension/restoration. */
|
||||||
|
export function syncOccupiedTupileLease(game: OccupiedTupileContext, state: OccupiedTupileState): OccupiedTupileSync {
|
||||||
|
validateOccupiedTupileState(state, game);
|
||||||
|
const authority = quoteOccupiedTupileAuthority(game);
|
||||||
|
if (authority.status === 'unknown' || !game.homeworldTupilePreview) return syncResult(state, authority);
|
||||||
|
const current = state.current === null ? null : state.leases[state.current];
|
||||||
|
if (current && authority.source && sameSource(current.source, authority.source) &&
|
||||||
|
sameHolders(current.holders, authority.holders) || !current && !authority.source) return syncResult(state, authority);
|
||||||
|
if (authority.source && (!current || !sameSource(current.source, authority.source))) requireTupile(
|
||||||
|
!state.leases.some(lease => sameSource(lease.source, authority.source!)), 'a cleared original occupation epoch cannot grant again.');
|
||||||
|
const leases = [...state.leases], cleanups = [...state.cleanups];
|
||||||
|
if (current) {
|
||||||
|
const lost = Object.freeze(authority.source && sameSource(current.source, authority.source)
|
||||||
|
? current.holders.filter(id => !authority.holders.includes(id)) : [...current.holders]);
|
||||||
|
leases[current.id] = Object.freeze({ ...current, lost });
|
||||||
|
for (const player of lost) cleanups.push(Object.freeze({ event: cleanupEvent(current, player),
|
||||||
|
lease: current.id, player, status: 'pending' }));
|
||||||
|
}
|
||||||
|
let next: number | null = null;
|
||||||
|
if (authority.source) {
|
||||||
|
next = leases.length;
|
||||||
|
leases.push(Object.freeze({ id: next, source: sourceCopy(authority.source),
|
||||||
|
holders: Object.freeze([...authority.holders]), turn: game.turn, lost: null }));
|
||||||
|
}
|
||||||
|
return syncResult(stateWith(leases, next, cleanups), authority);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read-only normal-limit projection; the parent filters actual HELD card IDs
|
||||||
|
* against its original committed/reserved-card protections. No face aliases,
|
||||||
|
* occupied-limit allowance or random discard is used for this cleanup. */
|
||||||
|
export function quoteOccupiedTupileCleanup(game: OccupiedTupileContext, state: OccupiedTupileState,
|
||||||
|
event: string): OccupiedTupileCleanupQuote {
|
||||||
|
validateOccupiedTupileState(state, game);
|
||||||
|
const receipt = state.cleanups.find(row => row.event === event);
|
||||||
|
requireTupile(receipt && receipt.status === 'pending', 'cleanup event is missing, completed or replayed.');
|
||||||
|
const player = game.players.find(seat => seat.id === receipt.player)!;
|
||||||
|
const limit = nativeHandLimit(player), authority = quoteOccupiedTupileAuthority(game);
|
||||||
|
return Object.freeze({ receipt, source: state.leases[receipt.lease].source, limit,
|
||||||
|
excess: Math.max(0, player.hand.length - limit), blocked: authority.blocked });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The original queue validates actor/event and protected distinct held IDs,
|
||||||
|
* performs exactly the quoted excess discards, then commits this next ledger in
|
||||||
|
* the SAME transaction. A pure receipt cannot prevent double physical discard
|
||||||
|
* by a caller that neglects atomic persistence. Complete zero-excess plans too.
|
||||||
|
* Later leases cannot reuse/consume a completed original cleanup event. */
|
||||||
|
export function completeOccupiedTupileCleanup(game: OccupiedTupileContext, state: OccupiedTupileState,
|
||||||
|
event: string, player: string): OccupiedTupileState {
|
||||||
|
const quote = quoteOccupiedTupileCleanup(game, state, event);
|
||||||
|
requireTupile(quote.receipt.player === player && !quote.blocked && quote.excess === 0,
|
||||||
|
'wrong original cleanup owner, blocked source or remaining normal-limit excess.');
|
||||||
|
const cleanups = state.cleanups.map(row => row.event === event ? Object.freeze({ ...row, status: 'completed' as const }) : row);
|
||||||
|
return stateWith([...state.leases], state.current, cleanups);
|
||||||
|
}
|
||||||
+6
-1
@@ -3850,6 +3850,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
'Printed occupied bank income is collected once and can be immediately allocated to a reciprocal ally. Separate Kaitain and Junction paid-receipt shares and Richese’s actual net sale income use frozen original invoices without charging the payer again. Southern Hemisphere rounds one completed actual native Collection total, including settled shared and occupied-bank credits; it redistributes rather than collecting twice. Ordinary native income cancellation remains distinct from printed occupied effects.',
|
'Printed occupied bank income is collected once and can be immediately allocated to a reciprocal ally. Separate Kaitain and Junction paid-receipt shares and Richese’s actual net sale income use frozen original invoices without charging the payer again. Southern Hemisphere rounds one completed actual native Collection total, including settled shared and occupied-bank credits; it redistributes rather than collecting twice. Ordinary native income cancellation remains distinct from printed occupied effects.',
|
||||||
'Proved Wallach IX occupation protects the occupier and ally from native Voice; Tleilax prevents native Face Dancers against them; Grumman prevents revealing Terror on their entry. Occupied Salusa removes Sardaukar advantage without changing starred physical identity. These printed effects cannot be restored or canceled with Karama. Other occupied powers and full Homeworld acceptance remain open.',
|
'Proved Wallach IX occupation protects the occupier and ally from native Voice; Tleilax prevents native Face Dancers against them; Grumman prevents revealing Terror on their entry. Occupied Salusa removes Sardaukar advantage without changing starred physical identity. These printed effects cannot be restored or canceled with Karama. Other occupied powers and full Homeworld acceptance remain open.',
|
||||||
'Caladan shares the admitted original Bidding inspection with the occupier, not its ally; native inspection can still be prevented independently. Ix gives the occupier the actual private drawn pool and selection, not unrelated setup or Technology. Richese sets its own method/direction before the occupier chooses only the physical cache card; seller, payment and other decisions stay native. Giedi gives one original purchase bonus to the occupier or its reciprocal ally with real receiver capacity, even when Harkonnen’s purchased card fills its hand.',
|
'Caladan shares the admitted original Bidding inspection with the occupier, not its ally; native inspection can still be prevented independently. Ix gives the occupier the actual private drawn pool and selection, not unrelated setup or Technology. Richese sets its own method/direction before the occupier chooses only the physical cache card; seller, payment and other decisions stay native. Giedi gives one original purchase bonus to the occupier or its reciprocal ally with real receiver capacity, even when Harkonnen’s purchased card fills its hand.',
|
||||||
|
'The separately marked fresh Tupile entry grants one hand slot to the proved occupier and reciprocal ally. Every server-derived hand limit includes it, so Harkonnen can hold nine without receiving a tenth bonus. After a proved loss, each old holder privately selects exactly its excess original eligible cards to return to the normal limit; committed cards remain protected. Original phase opening and remaining movement resume without another shipment or payment. Basic unknown lifecycle blocks rather than guesses cleanup. Advanced departure can restore CHOAM’s low intelligence without clearing its previous answers or used factions.',
|
||||||
'The card collection below is available for reference. Starting a Homeworld game remains disabled while shipment, battles, occupation, economy and faction interactions are being completed.',
|
'The card collection below is available for reference. Starting a Homeworld game remains disabled while shipment, battles, occupation, economy and faction interactions are being completed.',
|
||||||
],
|
],
|
||||||
example:
|
example:
|
||||||
@@ -3867,7 +3868,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
area: 'Implementation',
|
area: 'Implementation',
|
||||||
status: 'Partial',
|
status: 'Partial',
|
||||||
detail:
|
detail:
|
||||||
'All 13 cards, saved typed custody, supported-deck setup, Emperor movement, native Arrakis shipment sources, revival destinations, low charity/free-revival benefits, Tleilax’s phase-start income penalty and high-Salusa free Sardaukar support are connected. World-to-world invasion, Guild transport from Arrakis and Guild native returns use exact source pools, funding and interception. Homeworld battles connect native bonuses, limited explosion casualties, native-only Traitors and Face Dancers, and Ixian substitution. Junction offers connect sponsored board and Homeworld routes, including own returns, foreign departures and their distinct payments. Caladan foresight, Wallach advisor counts, Ix stronghold movement and Richese token movement now respect native population. High Tupile blocks Worthless sales and Ambassador payouts, including after an interrupted Ghola revival. Kaitain paid discards share the closing Bidding opportunity with CHOAM; Ecaz poison income uses actual physical disposal and current native population. Mandatory winning-card disposal follows winner casualties. Supported low Kaitain and Junction payment receipts preserve the payer’s cost and use current native population; divergent allied rounding remains guarded. Giedi grants its once-per-phase bonus on actual positive desert receipts, including settled Ecaz shares. Low Grumman uses original public entry counts across Terror and competing-reaction checks. Southern and Tleilax preserve the exact eligible revival group through one optional placement, with held income and explicit unresolved destination rules. Fresh occupation additionally connects bank and percentage receipts, completed Southern Collection, immediate sharing, Caladan inspection, displaced Ix pool, Richese card-only choice with native terms, real Giedi receiver choice and native protections. Advanced retains sole-qualified ownership until last departure; Basic lifecycle, Tupile capacity, exceptional Duke, concealed transport and full/public acceptance remain unfinished.',
|
'All 13 cards, saved typed custody, supported-deck setup, Emperor movement, native Arrakis shipment sources, revival destinations, low charity/free-revival benefits, Tleilax’s phase-start income penalty and high-Salusa free Sardaukar support are connected. World-to-world invasion, Guild transport from Arrakis and Guild native returns use exact source pools, funding and interception. Homeworld battles connect native bonuses, limited explosion casualties, native-only Traitors and Face Dancers, and Ixian substitution. Junction offers connect sponsored board and Homeworld routes, including own returns, foreign departures and their distinct payments. Caladan foresight, Wallach advisor counts, Ix stronghold movement and Richese token movement now respect native population. High Tupile blocks Worthless sales and Ambassador payouts, including after an interrupted Ghola revival. Kaitain paid discards share the closing Bidding opportunity with CHOAM; Ecaz poison income uses actual physical disposal and current native population. Mandatory winning-card disposal follows winner casualties. Supported low Kaitain and Junction payment receipts preserve the payer’s cost and use current native population; divergent allied rounding remains guarded. Giedi grants its once-per-phase bonus on actual positive desert receipts, including settled Ecaz shares. Low Grumman uses original public entry counts across Terror and competing-reaction checks. Southern and Tleilax preserve the exact eligible revival group through one optional placement, with held income and explicit unresolved destination rules. Fresh occupation additionally connects bank and percentage receipts, completed Southern Collection, immediate sharing, Caladan inspection, displaced Ix pool, Richese card-only choice with native terms, real Giedi receiver choice and native protections. Fresh native CHOAM entries additionally connect original Tupile slots, actual normal-limit cleanup and restored low-query authority without resetting usage. Advanced retains sole-qualified ownership until last departure; Basic lifecycle, exceptional Duke, concealed transport and full/public acceptance remain unfinished.',
|
||||||
evidence: [
|
evidence: [
|
||||||
'game/homeworld-cards.ts',
|
'game/homeworld-cards.ts',
|
||||||
'game/homeworld-game.ts',
|
'game/homeworld-game.ts',
|
||||||
@@ -3899,6 +3900,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
'game/homeworld-occupied-percentage.ts',
|
'game/homeworld-occupied-percentage.ts',
|
||||||
'game/homeworld-occupied-bidding.ts',
|
'game/homeworld-occupied-bidding.ts',
|
||||||
'game/homeworld-occupied-defenses.ts',
|
'game/homeworld-occupied-defenses.ts',
|
||||||
|
'game/homeworld-occupied-tupile.ts',
|
||||||
'game/tupile-intelligence.ts',
|
'game/tupile-intelligence.ts',
|
||||||
'game/tupile-intelligence-answer.ts',
|
'game/tupile-intelligence-answer.ts',
|
||||||
'game/tupile-intelligence-state.ts',
|
'game/tupile-intelligence-state.ts',
|
||||||
@@ -3931,6 +3933,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
'components/homeworld-occupied-bonus.tsx',
|
'components/homeworld-occupied-bonus.tsx',
|
||||||
'components/ix-technology.tsx',
|
'components/ix-technology.tsx',
|
||||||
'components/richese-auctions.tsx',
|
'components/richese-auctions.tsx',
|
||||||
|
'components/homeworld-tupile-cleanup.tsx',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -4048,6 +4051,8 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
'tests/homeworld-occupation-runtime.test.ts',
|
'tests/homeworld-occupation-runtime.test.ts',
|
||||||
'tests/homeworld-occupied-producers-engine.test.ts',
|
'tests/homeworld-occupied-producers-engine.test.ts',
|
||||||
'tests/homeworld-occupied-private-lots.test.ts',
|
'tests/homeworld-occupied-private-lots.test.ts',
|
||||||
|
'tests/homeworld-occupied-tupile.test.ts',
|
||||||
|
'tests/homeworld-occupied-tupile-runtime.test.ts',
|
||||||
'tests/grumman-collection.test.ts',
|
'tests/grumman-collection.test.ts',
|
||||||
'tests/grumman-collection-engine.test.ts',
|
'tests/grumman-collection-engine.test.ts',
|
||||||
'tests/grumman-collection-controls.test.ts',
|
'tests/grumman-collection-controls.test.ts',
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const DECISIONS = {
|
|||||||
homeworldOccupiedIncome: true,
|
homeworldOccupiedIncome: true,
|
||||||
homeworldOccupiedPercentage: true,
|
homeworldOccupiedPercentage: true,
|
||||||
homeworldOccupiedBonus: true,
|
homeworldOccupiedBonus: true,
|
||||||
|
homeworldTupileCleanup: true,
|
||||||
leaderSkillVisibility: true,
|
leaderSkillVisibility: true,
|
||||||
leaderSkillRevival: true,
|
leaderSkillRevival: true,
|
||||||
moritaniAssassinate: true,
|
moritaniAssassinate: true,
|
||||||
|
|||||||
@@ -32,16 +32,19 @@ export function advanceOriginal(game: Game, stop: (game: Game) => boolean, limit
|
|||||||
|
|
||||||
export function buyOriginalNormalLot(game: Game, buyer: string, amount: number): Game {
|
export function buyOriginalNormalLot(game: Game, buyer: string, amount: number): Game {
|
||||||
assert.ok(game.auction && !game.phaseOpening && !game.response && !game.decision);
|
assert.ok(game.auction && !game.phaseOpening && !game.response && !game.decision);
|
||||||
|
const card = game.auction.cards[game.auction.index].id;
|
||||||
for (let step = 0; game.auction!.active !== buyer && step < game.players.length; step++)
|
for (let step = 0; game.auction!.active !== buyer && step < game.players.length; step++)
|
||||||
game = applyAction(game, game.auction!.active, { type: 'passBid' });
|
game = applyAction(game, game.auction!.active, { type: 'passBid' });
|
||||||
assert.equal(game.auction!.active, buyer);
|
assert.equal(game.auction!.active, buyer);
|
||||||
game = applyAction(game, buyer, { type: 'bid', amount, allyPayment: 0 });
|
game = applyAction(game, buyer, { type: 'bid', amount, allyPayment: 0 });
|
||||||
for (let step = 0; !game.currentAuctionSale && step < game.players.length + 2; step++) {
|
for (let step = 0; !game.currentAuctionSale && !game.players.find(player => player.id === buyer)!.hand.some(held => held.id === card) &&
|
||||||
|
step < game.players.length + 2; step++) {
|
||||||
if (game.decision?.kind === 'auctionPayment')
|
if (game.decision?.kind === 'auctionPayment')
|
||||||
game = applyAction(game, buyer, { type: 'decision', karama: false });
|
game = applyAction(game, buyer, { type: 'decision', karama: false });
|
||||||
else game = applyAction(game, game.auction!.active, { type: 'passBid' });
|
else game = applyAction(game, game.auction!.active, { type: 'passBid' });
|
||||||
}
|
}
|
||||||
assert.ok(game.currentAuctionSale, 'the original purchase producer must own its paid invoice');
|
assert.ok(game.players.find(player => player.id === buyer)!.hand.some(held => held.id === card),
|
||||||
|
'the original purchase producer must deliver its actual physical card');
|
||||||
return game;
|
return game;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
applyAction, createGame, initializeHomeworldOccupationGameForAudit,
|
||||||
|
joinGame, newPlayer, viewGame, type Game,
|
||||||
|
} from '../game/engine';
|
||||||
|
import { FACTIONS, type FactionId } from '../game/catalog';
|
||||||
|
import { botActions } from '../game/bots';
|
||||||
|
import { homeworldContext, homeworldGameIntegrity } from '../game/homeworld-game';
|
||||||
|
import { observeHomeworldOccupation } from '../game/homeworld-occupation-history';
|
||||||
|
import { biddingPhysicalIds } from './fixture-homeworld-occupied-bidding';
|
||||||
|
import { assertDefenseInventory, clearDefenseNative } from './fixture-homeworld-occupied-defenses';
|
||||||
|
|
||||||
|
export const TUPILE_WORLD = 'homeworld:choam';
|
||||||
|
export const tupilePlayer = (game: Game, id: string) => game.players.find(player => player.id === id)!;
|
||||||
|
|
||||||
|
/** Real selected decks/lobby/fresh audit entry and all original setup choices.
|
||||||
|
* Later positions are explicitly labelled conserved fixtures, not play traces. */
|
||||||
|
export function freshTupileFixture(advanced = true, occupierFaction: 'guild' | 'moritani' = 'guild') {
|
||||||
|
const roster: [string, FactionId][] = [
|
||||||
|
['native', 'choam'], ['occupier', occupierFaction], ['ally', 'harkonnen'],
|
||||||
|
['observer', 'beneGesserit'], ['fremen', 'fremen'],
|
||||||
|
];
|
||||||
|
const expansions = [...new Set(roster.map(([, faction]) =>
|
||||||
|
FACTIONS.find(card => card.id === faction)!.expansion))].filter(expansion => expansion !== 'base');
|
||||||
|
let game = createGame('OCCUPIEDTUPILE', newPlayer('native', 'native', 'choam'), advanced, expansions);
|
||||||
|
for (const [id, faction] of roster.slice(1)) joinGame(game, newPlayer(id, id, faction));
|
||||||
|
game = applyAction(game, 'native', { type: 'homeworlds', enabled: true });
|
||||||
|
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
|
||||||
|
game = initializeHomeworldOccupationGameForAudit(game);
|
||||||
|
for (let step = 0; game.status === 'setup' && step < 100; step++) {
|
||||||
|
let progressed = false;
|
||||||
|
for (const player of game.players) {
|
||||||
|
const view = viewGame(game, player.id);
|
||||||
|
view.players.find(seat => seat.id === player.id)!.bot = 'Easy';
|
||||||
|
const action = botActions(view)[0];
|
||||||
|
if (!action) continue;
|
||||||
|
game = applyAction(game, player.id, action);
|
||||||
|
progressed = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
assert.ok(progressed, 'original Tupile setup has a legal next choice');
|
||||||
|
}
|
||||||
|
assert.equal(game.status, 'playing');
|
||||||
|
assert.equal(game.homeworldOccupationPreview, true);
|
||||||
|
assert.deepEqual(game.homeworldOccupationHistory!.qualifications, []);
|
||||||
|
assertDefenseInventory(game);
|
||||||
|
assert.equal(game.homeworldTupilePreview, true, 'only the original fresh entry initializes this profile');
|
||||||
|
const cards = biddingPhysicalIds(game);
|
||||||
|
const setup = structuredClone(game);
|
||||||
|
return { game, cards, setup };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recordTupilePosition(game: Game, label: string, cause: 'change' | 'turnStart' | 'turnEnd' = 'change') {
|
||||||
|
game.homeworldOccupationHistory = observeHomeworldOccupation(game.homeworldOccupationHistory!,
|
||||||
|
homeworldContext(game), game.homeworlds!.custody!, game.turn, cause,
|
||||||
|
`controlled-tupile-${label}-${game.homeworldOccupationHistory!.sources.length}`);
|
||||||
|
assertDefenseInventory(game);
|
||||||
|
homeworldGameIntegrity(game);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Typed transfer from the actual normal/starred reserve pools. Reserves is the
|
||||||
|
* aggregate pool; starred counters are a subset, not additional armies. */
|
||||||
|
export function addTupileForces(game: Game, id: string, normal = 1, elite = 0) {
|
||||||
|
const player = tupilePlayer(game, id);
|
||||||
|
assert.ok(player.reserves - (player.elites?.reserves ?? 0) >= normal);
|
||||||
|
assert.ok(elite === 0 || player.elites && player.elites.reserves >= elite);
|
||||||
|
player.reserves -= normal + elite;
|
||||||
|
if (elite) player.elites!.reserves -= elite;
|
||||||
|
const group = (game.homeworlds!.custody!.visitors[TUPILE_WORLD] ??= {})[id] ??= { normal: 0, elite: 0 };
|
||||||
|
group.normal += normal;
|
||||||
|
group.elite += elite;
|
||||||
|
assertDefenseInventory(game);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeTupileForces(game: Game, id: string) {
|
||||||
|
const group = game.homeworlds!.custody!.visitors[TUPILE_WORLD][id];
|
||||||
|
const player = tupilePlayer(game, id);
|
||||||
|
player.reserves += group.normal + group.elite;
|
||||||
|
if (group.elite) player.elites!.reserves += group.elite;
|
||||||
|
delete game.homeworlds!.custody!.visitors[TUPILE_WORLD][id];
|
||||||
|
if (!Object.keys(game.homeworlds!.custody!.visitors[TUPILE_WORLD]).length)
|
||||||
|
delete game.homeworlds!.custody!.visitors[TUPILE_WORLD];
|
||||||
|
assertDefenseInventory(game);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function occupiedTupileFixture(advanced = true, allied = true) {
|
||||||
|
const fixture = freshTupileFixture(advanced), game = fixture.game;
|
||||||
|
clearDefenseNative(game, TUPILE_WORLD);
|
||||||
|
if (allied) {
|
||||||
|
tupilePlayer(game, 'occupier').ally = 'ally';
|
||||||
|
tupilePlayer(game, 'ally').ally = 'occupier';
|
||||||
|
}
|
||||||
|
addTupileForces(game, 'occupier');
|
||||||
|
recordTupilePosition(game, 'original-native-casualties-and-sole-arrival');
|
||||||
|
assert.deepEqual(biddingPhysicalIds(game), fixture.cards);
|
||||||
|
return fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Conserved native repopulation fixture; no counters are created to cross high. */
|
||||||
|
export function restoreTupileNative(game: Game, amount: number) {
|
||||||
|
const native = tupilePlayer(game, 'native');
|
||||||
|
assert.ok(native.tanks >= amount);
|
||||||
|
native.tanks -= amount;
|
||||||
|
native.reserves += amount;
|
||||||
|
assertDefenseInventory(game);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Original-card discard at the caller-owned atomic settlement boundary. The
|
||||||
|
* helper itself neither chooses cards nor duplicates the original discard. */
|
||||||
|
export function discardTupileExcess(game: Game, id: string, limit: number) {
|
||||||
|
const before = biddingPhysicalIds(game), player = tupilePlayer(game, id);
|
||||||
|
while (player.hand.length > limit) game.discard.push(player.hand.pop()!);
|
||||||
|
assert.deepEqual(biddingPhysicalIds(game), before);
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { applyAction, normalizeAutomaticGame, RuleError, viewGame, handLimit, type Action, type Game } from '../game/engine';
|
||||||
|
import { botActions } from '../game/bots';
|
||||||
|
import { occupiedTupileFixture, freshTupileFixture, tupilePlayer, removeTupileForces, recordTupilePosition, restoreTupileNative, addTupileForces, TUPILE_WORLD } from './fixture-homeworld-occupied-tupile';
|
||||||
|
import { biddingPhysicalIds, stageBiddingHandSize } from './fixture-homeworld-occupied-bidding';
|
||||||
|
import { assertDefenseInventory, clearDefenseNative } from './fixture-homeworld-occupied-defenses';
|
||||||
|
import { advanceOriginal, buyOriginalNormalLot, cash } from './fixture-homeworld-occupied-producers';
|
||||||
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||||
|
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
|
||||||
|
|
||||||
|
function cleanupFixture() {
|
||||||
|
let game = normalizeAutomaticGame(occupiedTupileFixture(true, true).game);
|
||||||
|
assert.equal(handLimit(tupilePlayer(game, 'occupier')), 5);
|
||||||
|
assert.equal(handLimit(tupilePlayer(game, 'ally')), 9);
|
||||||
|
stageBiddingHandSize(game, 'occupier', 5);
|
||||||
|
stageBiddingHandSize(game, 'ally', 9);
|
||||||
|
const before = cash(game), cards = biddingPhysicalIds(game), phase = game.phase;
|
||||||
|
removeTupileForces(game, 'occupier');
|
||||||
|
recordTupilePosition(game, 'runtime-last-original-force-left');
|
||||||
|
game = normalizeAutomaticGame(JSON.parse(JSON.stringify(game)));
|
||||||
|
return { game, before, cards, phase };
|
||||||
|
}
|
||||||
|
|
||||||
|
void test('original Tupile holders discard their own actual excess once after departure, with private choices and all four legal policies', () => {
|
||||||
|
const fixture = cleanupFixture();
|
||||||
|
let game = fixture.game;
|
||||||
|
const completed: string[] = [];
|
||||||
|
for (let step = 0; game.decision?.kind === 'homeworldTupileCleanup' && step < 3; step++) {
|
||||||
|
const actor = game.decision.player, offer = viewGame(game, actor).homeworldTupileCleanup!;
|
||||||
|
assert.equal(offer.excess, 1);
|
||||||
|
assert.equal(offer.limit, actor === 'occupier' ? 4 : 8);
|
||||||
|
assert.equal(viewGame(game, 'native').homeworldTupileCleanup, null);
|
||||||
|
const input = structuredClone(game);
|
||||||
|
const action = { type: 'decision', event: offer.event, cards: [offer.eligibleCards[0]] };
|
||||||
|
assert.throws(() => applyAction(game, 'native', action), RuleError);
|
||||||
|
assert.throws(() => applyAction(game, actor, { ...action, event: `${offer.event}:stale` }), RuleError);
|
||||||
|
assert.throws(() => applyAction(game, actor, { ...action, cards: [] }), RuleError);
|
||||||
|
assert.throws(() => applyAction(game, actor, { ...action, cards: [action.cards[0], action.cards[0]] }), RuleError);
|
||||||
|
assert.deepEqual(game, input);
|
||||||
|
for (const difficulty of DIFFICULTIES) {
|
||||||
|
const view = viewGame(game, actor);
|
||||||
|
view.players.find(player => player.id === actor)!.bot = difficulty;
|
||||||
|
const choice = botActions(view)[0];
|
||||||
|
assert.ok(choice);
|
||||||
|
const result = applyAction(JSON.parse(JSON.stringify(game)), actor, choice);
|
||||||
|
assert.equal(tupilePlayer(result, actor).hand.length, offer.limit);
|
||||||
|
assert.deepEqual(biddingPhysicalIds(result), fixture.cards);
|
||||||
|
assert.deepEqual(cash(result), fixture.before);
|
||||||
|
}
|
||||||
|
game = applyAction(JSON.parse(JSON.stringify(game)), actor, action);
|
||||||
|
assert.ok(game.discard.some(card => card.id === action.cards[0]));
|
||||||
|
assert.equal(tupilePlayer(game, actor).hand.length, offer.limit);
|
||||||
|
assert.throws(() => applyAction(game, actor, action), RuleError);
|
||||||
|
completed.push(actor);
|
||||||
|
}
|
||||||
|
assert.deepEqual(completed.sort(), ['ally', 'occupier']);
|
||||||
|
assert.equal(game.pendingHomeworldTupileCleanup, null);
|
||||||
|
assert.equal(game.phase, fixture.phase);
|
||||||
|
assert.equal(handLimit(tupilePlayer(game, 'occupier')), 4);
|
||||||
|
assert.equal(handLimit(tupilePlayer(game, 'ally')), 8);
|
||||||
|
assert.deepEqual(cash(game), fixture.before);
|
||||||
|
assert.deepEqual(biddingPhysicalIds(game), fixture.cards);
|
||||||
|
assertDefenseInventory(game);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('native CHOAM low intelligence returns after the Advanced original departure without clearing prior usage or granting a new occupier', () => {
|
||||||
|
let game = normalizeAutomaticGame(occupiedTupileFixture(true, false).game);
|
||||||
|
assert.equal(viewGame(game, 'native').tupileIntelligence!.targets.find(row => row.player === 'occupier')!.blocked === null, false);
|
||||||
|
removeTupileForces(game, 'occupier');
|
||||||
|
restoreTupileNative(game, 1);
|
||||||
|
addTupileForces(game, 'fremen');
|
||||||
|
recordTupilePosition(game, 'runtime-native-and-unqualified-foreign-contact');
|
||||||
|
game = normalizeAutomaticGame(game);
|
||||||
|
assert.equal(viewGame(game, 'native').tupileIntelligence!.targets.find(row => row.player === 'fremen')!.blocked, null);
|
||||||
|
const original = biddingPhysicalIds(game), before = cash(game);
|
||||||
|
game = applyAction(game, 'native', { type: 'tupileIntelligence', target: 'fremen', category: 'weapons' });
|
||||||
|
assert.equal(game.tupileIntelligence!.receipts.at(-1)!.spice, before.fremen);
|
||||||
|
const frozen = structuredClone(game);
|
||||||
|
assert.throws(() => applyAction(game, 'native', { type: 'tupileIntelligence', target: 'fremen', category: 'defenses' }), RuleError);
|
||||||
|
assert.deepEqual(game, frozen);
|
||||||
|
assert.deepEqual(cash(game), before);
|
||||||
|
assert.deepEqual(biddingPhysicalIds(game), original);
|
||||||
|
assert.equal(handLimit(tupilePlayer(game, 'fremen')), 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('the actual native Harkonnen auction admits its ninth card under the proved allied Tupile slot and draws no tenth bonus', () => {
|
||||||
|
let game = freshTupileFixture(true).game;
|
||||||
|
game = advanceOriginal(game, game => game.phase === 2 && !game.phaseOpening && !game.response && !game.decision);
|
||||||
|
clearDefenseNative(game, TUPILE_WORLD);
|
||||||
|
tupilePlayer(game, 'occupier').ally = 'ally';
|
||||||
|
tupilePlayer(game, 'ally').ally = 'occupier';
|
||||||
|
addTupileForces(game, 'occupier');
|
||||||
|
recordTupilePosition(game, 'runtime-after-original-high-charity-sole-arrival');
|
||||||
|
game = normalizeAutomaticGame(game);
|
||||||
|
stageBiddingHandSize(game, 'ally', 8);
|
||||||
|
game = advanceOriginal(game, game => game.phase === 3 && !!game.auction && !game.phaseOpening && !game.response && !game.decision);
|
||||||
|
stageBiddingHandSize(game, 'ally', 8);
|
||||||
|
const original = biddingPhysicalIds(game), before = cash(game), card = game.auction!.cards[game.auction!.index].id;
|
||||||
|
assert.equal(viewGame(game, 'ally').players.find(player => player.id === 'ally')!.handLimit, 9);
|
||||||
|
game = buyOriginalNormalLot(game, 'ally', 5);
|
||||||
|
game = advanceOriginal(game, game => !game.currentAuctionSale && !game.response && !game.decision);
|
||||||
|
assert.equal(tupilePlayer(game, 'ally').hand.length, 9);
|
||||||
|
assert.ok(tupilePlayer(game, 'ally').hand.some(held => held.id === card));
|
||||||
|
assert.equal(tupilePlayer(game, 'ally').spice, before.ally - 5);
|
||||||
|
assert.deepEqual(biddingPhysicalIds(game), original);
|
||||||
|
assertDefenseInventory(game);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('older occupation profiles gain no new slot and an unbacked saved flag cannot authorize a larger hand', () => {
|
||||||
|
const game: Game = occupiedTupileFixture(true, true).game;
|
||||||
|
delete game.homeworldTupilePreview;
|
||||||
|
delete game.homeworldTupileState;
|
||||||
|
delete game.pendingHomeworldTupileCleanup;
|
||||||
|
const original = biddingPhysicalIds(game), before = cash(game);
|
||||||
|
const old = normalizeAutomaticGame(game);
|
||||||
|
assert.equal(viewGame(old, 'occupier').players.find(player => player.id === 'occupier')!.handLimit, 4);
|
||||||
|
assert.equal(viewGame(old, 'ally').players.find(player => player.id === 'ally')!.handLimit, 8);
|
||||||
|
const forged = structuredClone(old);
|
||||||
|
tupilePlayer(forged, 'occupier').tupileHandSlot = true;
|
||||||
|
const frozen = structuredClone(forged);
|
||||||
|
assert.throws(() => viewGame(forged, 'occupier'), RuleError);
|
||||||
|
assert.deepEqual(forged, frozen);
|
||||||
|
assert.deepEqual(biddingPhysicalIds(old), original);
|
||||||
|
assert.deepEqual(cash(old), before);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('actual paid foreign Tupile return queues both normal-limit cleanups and preserves original remaining movement without repeating forces or price', () => {
|
||||||
|
let game = freshTupileFixture(true).game;
|
||||||
|
game = advanceOriginal(game, game => game.phase === 2 && !game.phaseOpening && !game.response && !game.decision);
|
||||||
|
clearDefenseNative(game, TUPILE_WORLD);
|
||||||
|
tupilePlayer(game, 'occupier').ally = 'ally'; tupilePlayer(game, 'ally').ally = 'occupier';
|
||||||
|
addTupileForces(game, 'occupier');
|
||||||
|
recordTupilePosition(game, 'runtime-original-charity-before-real-return');
|
||||||
|
game = normalizeAutomaticGame(game);
|
||||||
|
for (let step = 0; !(game.phase === 5 && game.active === 'occupier' && !game.phaseOpening && !game.response && !game.decision) && step < 500; step++) {
|
||||||
|
let choice: { player: string; action: Action } | undefined;
|
||||||
|
if (game.phaseOpening) {
|
||||||
|
const player = game.players.find(player => !game.phaseOpening!.passed.includes(player.id));
|
||||||
|
if (player) choice = { player: player.id, action: { type: 'ready' } };
|
||||||
|
} else if (game.response) {
|
||||||
|
const player = game.players.find(player => !game.response!.passed.includes(player.id));
|
||||||
|
if (player) choice = { player: player.id, action: { type: 'passResponse' } };
|
||||||
|
} else if (game.decision?.kind === 'guildTiming')
|
||||||
|
choice = { player: game.decision.player, action: { type: 'decision', take: true } };
|
||||||
|
else if (game.phase === 3 && game.auction && !game.decision)
|
||||||
|
choice = { player: game.auction.active, action: { type: 'passBid' } };
|
||||||
|
else if (game.phase === 4 && !game.decision) {
|
||||||
|
const player = game.players.find(player => !game.ready.includes(player.id));
|
||||||
|
if (player) choice = { player: player.id, action: { type: 'ready' } };
|
||||||
|
} else if (game.phase === 5 && game.active && !game.decision)
|
||||||
|
choice = { player: game.active, action: { type: 'endMovement' } };
|
||||||
|
if (!choice) for (const player of game.players) {
|
||||||
|
const view = viewGame(game, player.id); view.players.find(seat => seat.id === player.id)!.bot = 'Easy';
|
||||||
|
const action = view.biddingEnd && !view.biddingEnd.ready.includes(player.id)
|
||||||
|
? { type: 'biddingEnd', event: view.biddingEnd.event, mode: 'ready' } : botActions(view)[0];
|
||||||
|
if (action) { choice = { player: player.id, action }; break; }
|
||||||
|
}
|
||||||
|
assert.ok(choice);
|
||||||
|
game = applyAction(game, choice.player, choice.action);
|
||||||
|
}
|
||||||
|
assert.equal(game.phase, 5); assert.equal(game.active, 'occupier');
|
||||||
|
stageBiddingHandSize(game, 'occupier', 5); stageBiddingHandSize(game, 'ally', 9);
|
||||||
|
const cards = biddingPhysicalIds(game), before = cash(game), reserves = tupilePlayer(game, 'occupier').reserves;
|
||||||
|
const quote = homeworldShipmentChoice(viewGame(game, 'occupier'), 'homeworld:guild',
|
||||||
|
{ [TUPILE_WORLD]: { normal: 1, elite: 0 } });
|
||||||
|
assert.equal(quote.blocked, null); assert.equal(quote.cost, 1); assert.ok(quote.action);
|
||||||
|
game = applyAction(JSON.parse(JSON.stringify(game)), 'occupier', quote.action);
|
||||||
|
for (let step = 0; game.decision?.kind !== 'homeworldTupileCleanup' && step < 40; step++) {
|
||||||
|
if (game.response) {
|
||||||
|
const player = game.players.find(player => !game.response!.passed.includes(player.id))!;
|
||||||
|
game = applyAction(game, player.id, { type: 'passResponse' });
|
||||||
|
} else if (game.decision?.kind === 'homeworldShipmentGuild')
|
||||||
|
game = applyAction(game, game.decision.player, { type: 'decision', allow: true, event: game.decision.event });
|
||||||
|
else game = normalizeAutomaticGame(game);
|
||||||
|
}
|
||||||
|
assert.equal(game.decision?.kind, 'homeworldTupileCleanup');
|
||||||
|
for (let step = 0; game.decision?.kind === 'homeworldTupileCleanup' && step < 3; step++) {
|
||||||
|
const player = game.decision.player, offer = viewGame(game, player).homeworldTupileCleanup!;
|
||||||
|
game = applyAction(JSON.parse(JSON.stringify(game)), player,
|
||||||
|
{ type: 'decision', event: offer.event, cards: offer.eligibleCards.slice(0, offer.excess) });
|
||||||
|
}
|
||||||
|
assert.equal(game.phase, 5); assert.equal(game.active, 'occupier');
|
||||||
|
assert.equal(tupilePlayer(game, 'occupier').shipped, true);
|
||||||
|
assert.equal(tupilePlayer(game, 'occupier').reserves, reserves + 1);
|
||||||
|
assert.equal(game.homeworlds!.custody!.visitors[TUPILE_WORLD], undefined);
|
||||||
|
assert.equal(tupilePlayer(game, 'occupier').spice, before.occupier - 1);
|
||||||
|
assert.equal(tupilePlayer(game, 'occupier').hand.length, 4);
|
||||||
|
assert.equal(tupilePlayer(game, 'ally').hand.length, 8);
|
||||||
|
assert.deepEqual(biddingPhysicalIds(game), cards); assertDefenseInventory(game);
|
||||||
|
const frozen = structuredClone(game);
|
||||||
|
assert.throws(() => applyAction(game, 'occupier', quote.action!), RuleError);
|
||||||
|
assert.deepEqual(game, frozen);
|
||||||
|
});
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { nativeHandLimit, type Game } from '../game/engine';
|
||||||
|
import { HomeworldCustodyError } from '../game/homeworld-custody';
|
||||||
|
import { homeworldContext } from '../game/homeworld-game';
|
||||||
|
import {
|
||||||
|
completeOccupiedTupileCleanup, createOccupiedTupileState, quoteOccupiedTupileAuthority,
|
||||||
|
quoteOccupiedTupileCleanup, syncOccupiedTupileLease, validateOccupiedTupileState,
|
||||||
|
type OccupiedTupileState,
|
||||||
|
} from '../game/homeworld-occupied-tupile';
|
||||||
|
import { quoteTupileIntelligenceRequest } from '../game/tupile-intelligence';
|
||||||
|
import { quoteTupileIntelligenceAnswer } from '../game/tupile-intelligence-answer';
|
||||||
|
import {
|
||||||
|
appendTupileIntelligenceObservation, tupileIntelligenceUsedFactions, validateTupileIntelligenceState,
|
||||||
|
} from '../game/tupile-intelligence-state';
|
||||||
|
import { quoteMoritaniAtomics } from '../game/moritani-atomics';
|
||||||
|
import { placeTerror, revealTerror } from '../game/moritani-terror';
|
||||||
|
import { biddingPhysicalIds, stageBiddingHandSize } from './fixture-homeworld-occupied-bidding';
|
||||||
|
import { assertDefenseInventory, clearDefenseNative } from './fixture-homeworld-occupied-defenses';
|
||||||
|
import {
|
||||||
|
addTupileForces, discardTupileExcess, freshTupileFixture, occupiedTupileFixture,
|
||||||
|
recordTupilePosition, removeTupileForces, restoreTupileNative, TUPILE_WORLD, tupilePlayer,
|
||||||
|
} from './fixture-homeworld-occupied-tupile';
|
||||||
|
|
||||||
|
/** Single parent transaction recipe: choose original held IDs, discard exactly
|
||||||
|
* the normal-limit excess, then persist the returned completion ledger. */
|
||||||
|
function settleAll(game: Game, state: OccupiedTupileState): OccupiedTupileState {
|
||||||
|
let next = state;
|
||||||
|
for (const receipt of state.cleanups.filter(row => row.status === 'pending')) {
|
||||||
|
const quote = quoteOccupiedTupileCleanup(game, next, receipt.event);
|
||||||
|
assert.equal(quote.blocked, null);
|
||||||
|
discardTupileExcess(game, receipt.player, quote.limit);
|
||||||
|
next = completeOccupiedTupileCleanup(game, next, receipt.event, receipt.player);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
void test('fresh setup and opt-in boundary preserve legacy profiles; original one-slot grants use each real receiver capacity', () => {
|
||||||
|
const { game, cards } = freshTupileFixture();
|
||||||
|
const empty = createOccupiedTupileState(), initial = structuredClone(game);
|
||||||
|
assert.equal(quoteOccupiedTupileAuthority(game).status, 'unoccupied');
|
||||||
|
assert.equal(syncOccupiedTupileLease(game, empty).state, empty);
|
||||||
|
assert.deepEqual(game, initial);
|
||||||
|
const occupied = occupiedTupileFixture();
|
||||||
|
const quote = quoteOccupiedTupileAuthority(occupied.game);
|
||||||
|
assert.equal(quote.provider, 'native');
|
||||||
|
assert.equal(quote.source!.occupier, 'occupier');
|
||||||
|
assert.deepEqual(quote.holders, ['occupier', 'ally']);
|
||||||
|
assert.deepEqual(quote.capacities, [
|
||||||
|
{ player: 'occupier', normal: 4, occupied: 5 }, { player: 'ally', normal: 8, occupied: 9 },
|
||||||
|
]);
|
||||||
|
assert.equal(quote.lowIntelligence, 'suppressed');
|
||||||
|
const before = structuredClone(occupied.game), sync = syncOccupiedTupileLease(occupied.game, empty);
|
||||||
|
assert.deepEqual(sync.slotHolders, ['occupier', 'ally']);
|
||||||
|
assert.deepEqual(occupied.game, before);
|
||||||
|
assert.equal(syncOccupiedTupileLease(occupied.game, sync.state).state, sync.state);
|
||||||
|
assert.equal(sync.state.leases.length, 1);
|
||||||
|
assert.deepEqual(sync.pending, []);
|
||||||
|
const legacy = structuredClone(occupied.game);
|
||||||
|
delete legacy.homeworldTupilePreview;
|
||||||
|
assert.equal(quoteOccupiedTupileAuthority(legacy).status, 'unoccupied');
|
||||||
|
assert.equal(quoteOccupiedTupileAuthority(legacy).lowIntelligence, 'available');
|
||||||
|
assert.equal(syncOccupiedTupileLease(legacy, empty).state, empty);
|
||||||
|
assert.deepEqual(biddingPhysicalIds(game), cards);
|
||||||
|
assert.deepEqual(biddingPhysicalIds(occupied.game), occupied.cards);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('retained Advanced source survives high-native, typed contest, extra armies and turns without another grant', () => {
|
||||||
|
const { game, cards } = occupiedTupileFixture(), first = syncOccupiedTupileLease(game, createOccupiedTupileState());
|
||||||
|
addTupileForces(game, 'occupier', 2);
|
||||||
|
addTupileForces(game, 'fremen', 1, 1);
|
||||||
|
restoreTupileNative(game, 11);
|
||||||
|
recordTupilePosition(game, 'typed-contest-native-high-and-extra-owner-armies');
|
||||||
|
game.turn++;
|
||||||
|
recordTupilePosition(game, 'next-original-turn', 'turnStart');
|
||||||
|
const before = structuredClone({ game, state: first.state }), quote = quoteOccupiedTupileAuthority(game);
|
||||||
|
assert.deepEqual(quote.source, first.authority.source);
|
||||||
|
assert.deepEqual(quote.capacities, first.authority.capacities);
|
||||||
|
assert.equal(quote.lowIntelligence, 'suppressed');
|
||||||
|
const current = syncOccupiedTupileLease(game, first.state);
|
||||||
|
assert.equal(current.state, first.state);
|
||||||
|
assert.deepEqual(current.pending, []);
|
||||||
|
assert.deepEqual({ game, state: first.state }, before);
|
||||||
|
assert.deepEqual(biddingPhysicalIds(game), cards);
|
||||||
|
assertDefenseInventory(game);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('Advanced last-force departure closes the original holder lease once and cleanup uses normal 4/8 not occupied 5/9', () => {
|
||||||
|
const { game, cards } = occupiedTupileFixture();
|
||||||
|
const first = syncOccupiedTupileLease(game, createOccupiedTupileState());
|
||||||
|
stageBiddingHandSize(game, 'occupier', 5);
|
||||||
|
stageBiddingHandSize(game, 'ally', 9);
|
||||||
|
removeTupileForces(game, 'occupier');
|
||||||
|
recordTupilePosition(game, 'last-original-force-departure');
|
||||||
|
const before = structuredClone({ game, state: first.state }), closed = syncOccupiedTupileLease(game, first.state);
|
||||||
|
assert.equal(closed.authority.status, 'unoccupied');
|
||||||
|
assert.equal(closed.authority.lowIntelligence, 'available');
|
||||||
|
assert.equal(closed.state.current, null);
|
||||||
|
assert.deepEqual(closed.slotHolders, []);
|
||||||
|
assert.deepEqual(closed.pending.map(row => row.player), ['occupier', 'ally']);
|
||||||
|
assert.deepEqual({ game, state: first.state }, before);
|
||||||
|
assert.equal(syncOccupiedTupileLease(game, closed.state).state, closed.state);
|
||||||
|
for (const [index, limit] of [4, 8].entries()) {
|
||||||
|
const receipt = closed.pending[index], plan = quoteOccupiedTupileCleanup(game, closed.state, receipt.event);
|
||||||
|
assert.equal(plan.limit, limit);
|
||||||
|
assert.equal(plan.excess, 1);
|
||||||
|
assert.deepEqual(plan.source, first.authority.source);
|
||||||
|
assert.throws(() => completeOccupiedTupileCleanup(game, closed.state, receipt.event, receipt.player), HomeworldCustodyError);
|
||||||
|
}
|
||||||
|
const settled = settleAll(game, closed.state);
|
||||||
|
validateOccupiedTupileState(settled, game);
|
||||||
|
assert.deepEqual(settled.cleanups.map(row => row.status), ['completed', 'completed']);
|
||||||
|
assert.equal(tupilePlayer(game, 'occupier').hand.length, 4);
|
||||||
|
assert.equal(tupilePlayer(game, 'ally').hand.length, 8);
|
||||||
|
const done = structuredClone({ game, state: settled });
|
||||||
|
assert.throws(() => completeOccupiedTupileCleanup(game, settled, closed.pending[0].event, 'occupier'), HomeworldCustodyError);
|
||||||
|
assert.deepEqual({ game, state: settled }, done);
|
||||||
|
assert.deepEqual(biddingPhysicalIds(game), cards);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('reciprocal ally removal and a new ally close only the frozen old ally; asymmetric alliances never grant', () => {
|
||||||
|
const { game } = occupiedTupileFixture();
|
||||||
|
const first = syncOccupiedTupileLease(game, createOccupiedTupileState());
|
||||||
|
stageBiddingHandSize(game, 'ally', 9);
|
||||||
|
tupilePlayer(game, 'ally').ally = null;
|
||||||
|
const removed = syncOccupiedTupileLease(game, first.state);
|
||||||
|
assert.deepEqual(removed.slotHolders, ['occupier']);
|
||||||
|
assert.deepEqual(removed.pending.map(row => row.player), ['ally']);
|
||||||
|
assert.deepEqual(first.lease!.holders, ['occupier', 'ally']);
|
||||||
|
assert.deepEqual(removed.lease!.holders, ['occupier']);
|
||||||
|
const settled = settleAll(game, removed.state);
|
||||||
|
tupilePlayer(game, 'occupier').ally = 'fremen';
|
||||||
|
assert.deepEqual(quoteOccupiedTupileAuthority(game).holders, ['occupier']);
|
||||||
|
tupilePlayer(game, 'fremen').ally = 'occupier';
|
||||||
|
const changed = syncOccupiedTupileLease(game, settled);
|
||||||
|
assert.deepEqual(changed.slotHolders, ['occupier', 'fremen']);
|
||||||
|
assert.deepEqual(changed.authority.source, first.authority.source);
|
||||||
|
assert.equal(changed.state.leases.length, 3);
|
||||||
|
assert.deepEqual(changed.pending, []);
|
||||||
|
assert.equal(syncOccupiedTupileLease(game, changed.state).state, changed.state);
|
||||||
|
validateOccupiedTupileState(changed.state, game);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('Advanced turnover and same-player return are new original epochs; pending old cleanup cannot lend new slots or replay', () => {
|
||||||
|
const { game } = occupiedTupileFixture(true, false);
|
||||||
|
const first = syncOccupiedTupileLease(game, createOccupiedTupileState());
|
||||||
|
stageBiddingHandSize(game, 'occupier', 5);
|
||||||
|
addTupileForces(game, 'fremen', 0, 1);
|
||||||
|
recordTupilePosition(game, 'incoming-typed-contest-retains-owner');
|
||||||
|
assert.equal(syncOccupiedTupileLease(game, first.state).state, first.state);
|
||||||
|
removeTupileForces(game, 'occupier');
|
||||||
|
recordTupilePosition(game, 'old-owner-last-departure-and-new-sole');
|
||||||
|
const turnover = syncOccupiedTupileLease(game, first.state);
|
||||||
|
assert.equal(turnover.lease!.source.occupier, 'fremen');
|
||||||
|
assert.notEqual(turnover.lease!.source.qualification, first.lease!.source.qualification);
|
||||||
|
assert.deepEqual(turnover.slotHolders, ['fremen']);
|
||||||
|
assert.deepEqual(turnover.pending.map(row => row.player), ['occupier']);
|
||||||
|
removeTupileForces(game, 'fremen');
|
||||||
|
recordTupilePosition(game, 'successor-last-force-departure');
|
||||||
|
const departed = syncOccupiedTupileLease(game, turnover.state);
|
||||||
|
addTupileForces(game, 'occupier');
|
||||||
|
recordTupilePosition(game, 'same-player-new-sole-return');
|
||||||
|
const returned = syncOccupiedTupileLease(game, departed.state);
|
||||||
|
assert.equal(returned.lease!.source.occupier, 'occupier');
|
||||||
|
assert.notEqual(returned.lease!.source.qualification, first.lease!.source.qualification);
|
||||||
|
assert.deepEqual(returned.slotHolders, []);
|
||||||
|
const settled = settleAll(game, returned.state), renewed = syncOccupiedTupileLease(game, settled);
|
||||||
|
assert.deepEqual(renewed.slotHolders, ['occupier']);
|
||||||
|
assert.equal(renewed.state.leases.length, 3);
|
||||||
|
assert.throws(() => quoteOccupiedTupileCleanup(game, settled, turnover.pending[0].event), HomeworldCustodyError);
|
||||||
|
removeTupileForces(game, 'occupier');
|
||||||
|
recordTupilePosition(game, 'returned-owner-new-last-departure');
|
||||||
|
const final = syncOccupiedTupileLease(game, settled);
|
||||||
|
assert.notEqual(final.pending[0].event, turnover.pending[0].event);
|
||||||
|
assert.equal(final.pending[0].lease, 2);
|
||||||
|
assert.equal(quoteOccupiedTupileCleanup(game, final.state, final.pending[0].event).excess, 0);
|
||||||
|
validateOccupiedTupileState(final.state, game);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('Basic unobserved turn, contest, native repopulation, competing source and departure remain unknown/read-only, never guessed cleanup', () => {
|
||||||
|
for (const mode of ['turn', 'contest', 'nativeHigh', 'departure', 'competitor'] as const) {
|
||||||
|
const { game, cards } = occupiedTupileFixture(false);
|
||||||
|
const first = syncOccupiedTupileLease(game, createOccupiedTupileState());
|
||||||
|
if (mode === 'turn') {
|
||||||
|
game.turn++;
|
||||||
|
// Controlled incomplete boundary: no current-turn qualification is fabricated.
|
||||||
|
} else if (mode === 'nativeHigh') {
|
||||||
|
restoreTupileNative(game, 11);
|
||||||
|
recordTupilePosition(game, 'pending-native-repopulation');
|
||||||
|
} else if (mode === 'departure') {
|
||||||
|
removeTupileForces(game, 'occupier');
|
||||||
|
recordTupilePosition(game, 'pending-basic-departure');
|
||||||
|
} else {
|
||||||
|
addTupileForces(game, 'fremen', 1, 1);
|
||||||
|
if (mode === 'competitor') removeTupileForces(game, 'occupier');
|
||||||
|
recordTupilePosition(game, `pending-basic-${mode}`);
|
||||||
|
}
|
||||||
|
const before = structuredClone({ game, state: first.state }), quote = quoteOccupiedTupileAuthority(game);
|
||||||
|
assert.equal(quote.status, 'unknown');
|
||||||
|
assert.equal(quote.lowIntelligence, 'unknown');
|
||||||
|
assert.ok(quote.blocked);
|
||||||
|
assert.equal(quote.source, null);
|
||||||
|
assert.deepEqual(quote.holders, []);
|
||||||
|
const result = syncOccupiedTupileLease(game, first.state);
|
||||||
|
assert.equal(result.state.current, first.state.current);
|
||||||
|
assert.deepEqual(result.pending, []);
|
||||||
|
assert.deepEqual({ game, state: first.state }, before);
|
||||||
|
assert.deepEqual(biddingPhysicalIds(game), cards);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('Basic observed continuous turn qualification keeps one existing slot lease without a new cleanup or stacked allowance', () => {
|
||||||
|
const { game, cards } = occupiedTupileFixture(false);
|
||||||
|
const first = syncOccupiedTupileLease(game, createOccupiedTupileState());
|
||||||
|
game.turn++;
|
||||||
|
recordTupilePosition(game, 'original-basic-observed-next-turn', 'turnStart');
|
||||||
|
const next = syncOccupiedTupileLease(game, first.state);
|
||||||
|
assert.equal(next.authority.status, 'occupied');
|
||||||
|
assert.equal(next.authority.capacities.find(row => row.player === 'occupier')!.occupied, 5);
|
||||||
|
assert.equal(next.authority.capacities.find(row => row.player === 'ally')!.occupied, 9);
|
||||||
|
assert.equal(next.state.leases.length, 1);
|
||||||
|
assert.equal(next.pending.length, 0);
|
||||||
|
assert.deepEqual(biddingPhysicalIds(game), cards);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('unobserved Advanced writes cannot fabricate a new source or resurrect a closed original epoch', () => {
|
||||||
|
const { game } = occupiedTupileFixture(true, false);
|
||||||
|
const first = syncOccupiedTupileLease(game, createOccupiedTupileState());
|
||||||
|
removeTupileForces(game, 'occupier');
|
||||||
|
const closed = syncOccupiedTupileLease(game, first.state);
|
||||||
|
assert.equal(closed.authority.status, 'unoccupied');
|
||||||
|
addTupileForces(game, 'occupier');
|
||||||
|
const stale = structuredClone({ game, state: closed.state });
|
||||||
|
assert.throws(() => syncOccupiedTupileLease(game, closed.state), HomeworldCustodyError);
|
||||||
|
assert.deepEqual({ game, state: closed.state }, stale);
|
||||||
|
const fresh = freshTupileFixture().game;
|
||||||
|
clearDefenseNative(fresh, TUPILE_WORLD);
|
||||||
|
addTupileForces(fresh, 'occupier');
|
||||||
|
const quote = quoteOccupiedTupileAuthority(fresh);
|
||||||
|
assert.equal(quote.status, 'unknown');
|
||||||
|
assert.equal(quote.source, null);
|
||||||
|
assert.equal(syncOccupiedTupileLease(fresh, createOccupiedTupileState()).state.leases.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('native CHOAM cannot receive an allied self-world slot; no alternative provider is chosen', () => {
|
||||||
|
const { game } = occupiedTupileFixture(true, false);
|
||||||
|
tupilePlayer(game, 'occupier').ally = 'native';
|
||||||
|
tupilePlayer(game, 'native').ally = 'occupier';
|
||||||
|
const quote = quoteOccupiedTupileAuthority(game);
|
||||||
|
assert.equal(quote.status, 'unknown');
|
||||||
|
assert.equal(quote.provider, 'native');
|
||||||
|
assert.deepEqual(quote.holders, []);
|
||||||
|
assert.equal(syncOccupiedTupileLease(game, createOccupiedTupileState()).state.leases.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('strict persisted JSON keeps original source/cleanup epochs and rejects malformed or rewritten grants immutably', () => {
|
||||||
|
const { game } = occupiedTupileFixture();
|
||||||
|
const first = syncOccupiedTupileLease(game, createOccupiedTupileState());
|
||||||
|
removeTupileForces(game, 'occupier');
|
||||||
|
recordTupilePosition(game, 'json-original-last-departure');
|
||||||
|
const closed = syncOccupiedTupileLease(game, first.state), state = JSON.parse(JSON.stringify(closed.state)) as OccupiedTupileState;
|
||||||
|
validateOccupiedTupileState(state, game);
|
||||||
|
assert.equal(syncOccupiedTupileLease(game, state).state, state);
|
||||||
|
assert.deepEqual(quoteOccupiedTupileCleanup(game, state, closed.pending[0].event).source, first.authority.source);
|
||||||
|
const bad = [
|
||||||
|
{ ...state, signature: 'rewritten' },
|
||||||
|
{ ...state, current: 0 },
|
||||||
|
{ ...state, cleanups: state.cleanups.slice(1) },
|
||||||
|
{ ...state, extra: true },
|
||||||
|
{ ...state, leases: [{ ...state.leases[0], source: { ...state.leases[0].source, native: 'ally' } }] },
|
||||||
|
{ ...state, leases: [{ ...state.leases[0], source: { ...state.leases[0].source, signature: 'not original' } }] },
|
||||||
|
{ ...state, leases: [{ ...state.leases[0], holders: ['occupier', 'native'] }] },
|
||||||
|
{ ...state, cleanups: [{ ...state.cleanups[0], player: 'fremen' }, state.cleanups[1]] },
|
||||||
|
];
|
||||||
|
for (const malformed of bad) {
|
||||||
|
// Even a newly computed consistency signature cannot rewrite source identity.
|
||||||
|
const signed = { ...malformed, signature: JSON.stringify([1, malformed.leases, malformed.current, malformed.cleanups]) };
|
||||||
|
const candidate = malformed.signature === 'rewritten' ? malformed : signed;
|
||||||
|
const before = structuredClone({ game, candidate });
|
||||||
|
assert.throws(() => validateOccupiedTupileState(candidate as OccupiedTupileState, game), HomeworldCustodyError);
|
||||||
|
assert.deepEqual({ game, candidate }, before);
|
||||||
|
}
|
||||||
|
assert.throws(() => completeOccupiedTupileCleanup(game, state, closed.pending[0].event, 'fremen'), HomeworldCustodyError);
|
||||||
|
assert.throws(() => quoteOccupiedTupileCleanup(game, state, 'other-epoch'), HomeworldCustodyError);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('suppression and Advanced expiry preserve actual historical private intelligence and lifetime faction use', () => {
|
||||||
|
const { game } = freshTupileFixture();
|
||||||
|
clearDefenseNative(game, TUPILE_WORLD);
|
||||||
|
restoreTupileNative(game, 1);
|
||||||
|
addTupileForces(game, 'occupier');
|
||||||
|
recordTupilePosition(game, 'native-low-contact-without-sole-qualification');
|
||||||
|
const request = quoteTupileIntelligenceRequest(homeworldContext(game), game.homeworlds!.custody!, 'native', [],
|
||||||
|
quoteOccupiedTupileAuthority(game).status, 'occupier', 'weapons');
|
||||||
|
const target = tupilePlayer(game, 'occupier');
|
||||||
|
const answer = quoteTupileIntelligenceAnswer(target.hand, target.spice, request.category);
|
||||||
|
game.tupileIntelligence = appendTupileIntelligenceObservation(game.tupileIntelligence!, {
|
||||||
|
...request, ...answer, event: 'controlled-actual-original-contact-answer', turn: game.turn, phase: game.phase,
|
||||||
|
});
|
||||||
|
const observed = structuredClone(game.tupileIntelligence);
|
||||||
|
clearDefenseNative(game, TUPILE_WORLD);
|
||||||
|
recordTupilePosition(game, 'native-last-force-leaves-sole-occupation');
|
||||||
|
const first = syncOccupiedTupileLease(game, createOccupiedTupileState());
|
||||||
|
assert.equal(first.authority.lowIntelligence, 'suppressed');
|
||||||
|
assert.throws(() => quoteTupileIntelligenceRequest(homeworldContext(game), game.homeworlds!.custody!, 'native',
|
||||||
|
tupileIntelligenceUsedFactions(game.tupileIntelligence!), first.authority.status, 'occupier', 'defenses'), HomeworldCustodyError);
|
||||||
|
removeTupileForces(game, 'occupier');
|
||||||
|
recordTupilePosition(game, 'intelligence-owner-epoch-expires');
|
||||||
|
syncOccupiedTupileLease(game, first.state);
|
||||||
|
validateTupileIntelligenceState(game.tupileIntelligence!, game.players, game.turn);
|
||||||
|
assert.deepEqual(game.tupileIntelligence, observed);
|
||||||
|
assert.deepEqual(tupileIntelligenceUsedFactions(game.tupileIntelligence!), ['guild']);
|
||||||
|
assert.deepEqual(game.tupileIntelligence!.receipts[0].spice, answer.spice);
|
||||||
|
assert.deepEqual(game.tupileIntelligence!.receipts[0].count, answer.count);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('original Atomics hand reduction composes with one Tupile slot and cleanup returns to penalized normal capacity', () => {
|
||||||
|
// Pure-authority composition position, NOT a claim that the current engine
|
||||||
|
// enables combined Atomics/Homeworld arrival play. Original setup/cards and
|
||||||
|
// the actual original placed/revealed token supply all provenance.
|
||||||
|
const { game, cards } = freshTupileFixture(true, 'moritani');
|
||||||
|
const owner = tupilePlayer(game, 'occupier'), ally = tupilePlayer(game, 'ally');
|
||||||
|
owner.ally = ally.id;
|
||||||
|
ally.ally = owner.id;
|
||||||
|
clearDefenseNative(game, TUPILE_WORLD);
|
||||||
|
addTupileForces(game, owner.id);
|
||||||
|
recordTupilePosition(game, 'composition-sole-owner-with-original-ally');
|
||||||
|
const token = game.moritaniTerror!.tokens.find(row => row.kind === 'atomics')!;
|
||||||
|
game.moritaniTerror = placeTerror(game.moritaniTerror!, token.id, 'arrakeen', game.turn);
|
||||||
|
const placed = game.moritaniTerror.tokens.find(row => row.id === token.id)!;
|
||||||
|
const plan = quoteMoritaniAtomics({ token: placed, territory: 'arrakeen', moritaniId: owner.id,
|
||||||
|
turn: game.turn, aftermath: null, players: game.players.map(player => ({ ...player,
|
||||||
|
handSize: player.hand.length, baseHandLimit: nativeHandLimit(player) })) });
|
||||||
|
game.moritaniTerror = revealTerror(game.moritaniTerror, token.id);
|
||||||
|
game.moritaniAtomics = plan.aftermath;
|
||||||
|
for (const casualty of plan.casualties) {
|
||||||
|
const player = tupilePlayer(game, casualty.playerId);
|
||||||
|
player.forces[casualty.location] -= casualty.normal + casualty.elite;
|
||||||
|
player.tanks += casualty.normal + casualty.elite;
|
||||||
|
if (casualty.elite) {
|
||||||
|
player.elites!.forces[casualty.location] -= casualty.elite;
|
||||||
|
player.elites!.tanks += casualty.elite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const reduction of plan.handReductions) {
|
||||||
|
tupilePlayer(game, reduction.playerId).atomicsHandLimitPenalty = true;
|
||||||
|
discardTupileExcess(game, reduction.playerId, reduction.limit);
|
||||||
|
}
|
||||||
|
const first = syncOccupiedTupileLease(game, createOccupiedTupileState());
|
||||||
|
assert.deepEqual(first.authority.capacities, [
|
||||||
|
{ player: 'occupier', normal: 3, occupied: 4 }, { player: 'ally', normal: 7, occupied: 8 },
|
||||||
|
]);
|
||||||
|
stageBiddingHandSize(game, 'occupier', 4);
|
||||||
|
stageBiddingHandSize(game, 'ally', 8);
|
||||||
|
removeTupileForces(game, 'occupier');
|
||||||
|
recordTupilePosition(game, 'composition-original-last-departure');
|
||||||
|
const closed = syncOccupiedTupileLease(game, first.state);
|
||||||
|
assert.deepEqual(closed.pending.map(row => quoteOccupiedTupileCleanup(game, closed.state, row.event).limit), [3, 7]);
|
||||||
|
settleAll(game, closed.state);
|
||||||
|
assert.equal(owner.hand.length, 3);
|
||||||
|
assert.equal(ally.hand.length, 7);
|
||||||
|
assert.deepEqual(biddingPhysicalIds(game), cards);
|
||||||
|
assertDefenseInventory(game);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('direct reciprocal ally replacement freezes former-holder cleanup and Basic uncertainty blocks its settlement without changing receipts', () => {
|
||||||
|
const { game } = occupiedTupileFixture(false);
|
||||||
|
const first = syncOccupiedTupileLease(game, createOccupiedTupileState());
|
||||||
|
stageBiddingHandSize(game, 'ally', 9);
|
||||||
|
tupilePlayer(game, 'ally').ally = null;
|
||||||
|
tupilePlayer(game, 'occupier').ally = 'fremen';
|
||||||
|
tupilePlayer(game, 'fremen').ally = 'occupier';
|
||||||
|
const replacement = syncOccupiedTupileLease(game, first.state);
|
||||||
|
assert.deepEqual(replacement.slotHolders, ['occupier', 'fremen']);
|
||||||
|
assert.deepEqual(replacement.pending.map(row => row.player), ['ally']);
|
||||||
|
const event = replacement.pending[0].event;
|
||||||
|
assert.equal(quoteOccupiedTupileCleanup(game, replacement.state, event).limit, 8);
|
||||||
|
assert.equal(quoteOccupiedTupileCleanup(game, replacement.state, event).excess, 1);
|
||||||
|
removeTupileForces(game, 'occupier');
|
||||||
|
recordTupilePosition(game, 'pending-basic-expiry-with-original-former-ally-cleanup');
|
||||||
|
const before = structuredClone({ game, state: replacement.state });
|
||||||
|
const unknown = syncOccupiedTupileLease(game, replacement.state);
|
||||||
|
assert.equal(unknown.authority.status, 'unknown');
|
||||||
|
assert.equal(unknown.state, replacement.state);
|
||||||
|
const blocked = quoteOccupiedTupileCleanup(game, replacement.state, event);
|
||||||
|
assert.ok(blocked.blocked);
|
||||||
|
assert.equal(blocked.receipt.player, 'ally');
|
||||||
|
assert.equal(blocked.source.occupier, 'occupier');
|
||||||
|
assert.deepEqual({ game, state: replacement.state }, before);
|
||||||
|
// A separate conserved hand transfer is not permission to settle unknown expiry.
|
||||||
|
stageBiddingHandSize(game, 'ally', 8);
|
||||||
|
const blockedBefore = structuredClone({ game, state: replacement.state });
|
||||||
|
assert.throws(() => completeOccupiedTupileCleanup(game, replacement.state, event, 'ally'), HomeworldCustodyError);
|
||||||
|
assert.deepEqual({ game, state: replacement.state }, blockedBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('an active Tupile marker without its original occupation profile is unknown and cannot close existing grants', () => {
|
||||||
|
const { game } = occupiedTupileFixture();
|
||||||
|
const first = syncOccupiedTupileLease(game, createOccupiedTupileState());
|
||||||
|
delete game.homeworldOccupationPreview;
|
||||||
|
const before = structuredClone({ game, state: first.state });
|
||||||
|
const quote = quoteOccupiedTupileAuthority(game);
|
||||||
|
assert.equal(quote.status, 'unknown');
|
||||||
|
assert.equal(quote.source, null);
|
||||||
|
assert.deepEqual(quote.holders, []);
|
||||||
|
assert.equal(syncOccupiedTupileLease(game, first.state).state, first.state);
|
||||||
|
assert.deepEqual({ game, state: first.state }, before);
|
||||||
|
});
|
||||||
@@ -38,6 +38,7 @@ async function main() {
|
|||||||
'\nThe same fresh skill/Nexus entry admits standalone Ecaz OR Moritani with classics, exact ecaz33/all14/all12, Basic/Advanced2–6. Optional unused Tech requires3+; Strongholds requires Advanced; both3+ Advanced. Native Ecaz five-disc/Duke assignment and quiet living-Duke Cunning, plus original Advanced Moritani skill-first assassination/normal-traitor forfeiture, remain. Advanced Harkonnen, shared-Duke assignment, enhanced Terror relocation/HMS/Grumman and other pending effects stay guarded.',
|
'\nThe same fresh skill/Nexus entry admits standalone Ecaz OR Moritani with classics, exact ecaz33/all14/all12, Basic/Advanced2–6. Optional unused Tech requires3+; Strongholds requires Advanced; both3+ Advanced. Native Ecaz five-disc/Duke assignment and quiet living-Duke Cunning, plus original Advanced Moritani skill-first assassination/normal-traitor forfeiture, remain. Advanced Harkonnen, shared-Duke assignment, enhanced Terror relocation/HMS/Grumman and other pending effects stay guarded.',
|
||||||
'\nIn the standalone Ecaz skill/Nexus profile, mandatory Advanced Occupy composes the chosen faction’s skill with separately labelled armies. Ecaz-led Suk rescues its own fixed casualties after the ally’s variable losses; Diplomat uses the chosen faction’s undialed own pool. Shared training, Basic odd rounding and other pending effects remain separate.',
|
'\nIn the standalone Ecaz skill/Nexus profile, mandatory Advanced Occupy composes the chosen faction’s skill with separately labelled armies. Ecaz-led Suk rescues its own fixed casualties after the ally’s variable losses; Diplomat uses the chosen faction’s undialed own pool. Shared training, Basic odd rounding and other pending effects remain separate.',
|
||||||
'\nHomeworld-occupation opts a fresh ready 2–6-seat Basic/Advanced lobby with original selected faction decks into occupied bank Collection/immediate ally sharing, Kaitain/Junction/Richese percentage receipts, completed total Southern Hemisphere Collection, Caladan shared inspection, displaced Ix pool control, Richese card-only selection with native seller terms, Giedi receiver choice and occupied native defenses. Advanced follows the authorized supplied rulebook p22: sole foreign qualification, retained until its last force leaves; later contests/native repopulation do not expire it, and a new sole arrival after departure has a new source epoch. Basic retains unresolved lifecycle guards. Native income/peek cancellation remains distinct from printed occupied effects. Other borrowed/exceptional powers, overlays, public starts and old-game conversion remain separate.',
|
'\nHomeworld-occupation opts a fresh ready 2–6-seat Basic/Advanced lobby with original selected faction decks into occupied bank Collection/immediate ally sharing, Kaitain/Junction/Richese percentage receipts, completed total Southern Hemisphere Collection, Caladan shared inspection, displaced Ix pool control, Richese card-only selection with native seller terms, Giedi receiver choice and occupied native defenses. Advanced follows the authorized supplied rulebook p22: sole foreign qualification, retained until its last force leaves; later contests/native repopulation do not expire it, and a new sole arrival after departure has a new source epoch. Basic retains unresolved lifecycle guards. Native income/peek cancellation remains distinct from printed occupied effects. Other borrowed/exceptional powers, overlays, public starts and old-game conversion remain separate.',
|
||||||
|
'\nWhen native CHOAM is seated, this fresh entry additionally marks original occupied Tupile hand slots and owner-selected normal-limit cleanup. The occupier/reciprocal ally gain one validated slot through existing capacity consumers; Advanced departure and ally removal queue the original lost-holder cleanup. Private selected-ID discards preserve current phase/opening and remaining movement, with no new card or fee. Known occupation suppresses new native low-intelligence queries; proved Advanced expiry restores eligibility without resetting historical answers/usage. Basic unknown cases retain source guards. Unmarked played profiles are not upgraded.',
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user