Connect Sandmaster collection to physical Ambassador relocation
This commit is contained in:
1 parent
7cef1a3412
commit
039fe4e87f
13 files changed
+175
-46
No files matched your search
@@ -3,6 +3,7 @@
|
||||
import { useId, useState } from 'react';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { location, splitLocation, territory } from '@/game/board';
|
||||
import { sandmasterDestinationCollection } from '@/game/sandmaster-movement';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
|
||||
@@ -29,6 +30,7 @@ export function AmbassadorMovement({
|
||||
const [elites, setElites] = useState<Record<string, number>>({});
|
||||
const [takeMarker, setTakeMarker] = useState(false);
|
||||
const [fighters, setFighters] = useState(false);
|
||||
const [collectSandmaster, setCollectSandmaster] = useState(true);
|
||||
const entry = game.ambassadorEntry;
|
||||
if (
|
||||
entry?.stage !== 'move' ||
|
||||
@@ -78,6 +80,9 @@ export function AmbassadorMovement({
|
||||
!!source.marker &&
|
||||
location(source.territory, source.marker.sector) === target;
|
||||
const includesMarker = !!source.marker && takeMarker && !markerAtTarget;
|
||||
const sandmaster = !includesMarker && destination
|
||||
? sandmasterDestinationCollection(game, game.me, source.territory, destination.territory, destination.sector)
|
||||
: null;
|
||||
const reason =
|
||||
destination?.blocked ??
|
||||
(!destination
|
||||
@@ -270,6 +275,17 @@ export function AmbassadorMovement({
|
||||
Request a flip to fighters on arrival
|
||||
</label>
|
||||
)}
|
||||
{sandmaster && (
|
||||
<label className="flex min-h-11 items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={collectSandmaster && !sandmaster.blocked}
|
||||
disabled={busy || !!sandmaster.blocked}
|
||||
onChange={(event) => setCollectSandmaster(event.target.checked)}
|
||||
/>
|
||||
{sandmaster.blocked ?? 'Collect 1 spice here with Sandmaster before arrival reactions'}
|
||||
</label>
|
||||
)}
|
||||
<p className="m-0 text-sm" aria-live="polite">
|
||||
Selected: {total} physical forces
|
||||
{includesMarker ? ' and one concealed No-Field' : ''}.
|
||||
@@ -301,6 +317,7 @@ export function AmbassadorMovement({
|
||||
}
|
||||
: {}),
|
||||
...(fighters && destination.canFight ? { fighters: true } : {}),
|
||||
...(sandmaster?.blocked === null ? { sandmasterCollect: collectSandmaster } : {}),
|
||||
});
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { SandmasterWormCollection } from '@/game/sandmaster-worm';
|
||||
import type { SandmasterDestinationCollection } from '@/game/sandmaster-movement';
|
||||
|
||||
export function SandmasterWormChoice({ quote, collect, onChange }: {
|
||||
quote: SandmasterWormCollection | null;
|
||||
quote: SandmasterDestinationCollection | null;
|
||||
collect: boolean;
|
||||
onChange: (collect: boolean) => void;
|
||||
}) {
|
||||
|
||||
@@ -132,6 +132,16 @@ without a city sentinel. Three direct scenarios, one cross-mode arrival/ending
|
||||
regression, types/lint and native GameTable SSR pre-arrival/ending controls
|
||||
pass; no broad suite or deployed proof was run.
|
||||
|
||||
**Sandmaster / physical Fremen Ambassador connected.** Relocation now offers
|
||||
optional destination collection before arrival reactions, without an invented
|
||||
route or extra ordinary move. Shared quotes preserve trainer/profile and
|
||||
single-pile bounds; CHOAM-suspended orders keep the distinct collection receipt.
|
||||
Basic/Advanced direct smokes and the adjacent native worm path pass. Live React
|
||||
controls in Chromium reach native collection and opt-out outcomes with exact
|
||||
spice/counters and completed events. No broad suite, recovery campaign or
|
||||
deployed acceptance was run.
|
||||
Compiler/lint and two affected Ambassador/worm contract cases pass.
|
||||
|
||||
**7 October 2026 — five pending rulings resolved by the user.** The user
|
||||
answered five recorded questions; each is now recorded in
|
||||
[the decision index](RULE_DECISIONS.md#user-rulings--7-october-2026) and
|
||||
|
||||
@@ -375,7 +375,7 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP
|
||||
| Ixian Technology on Richese lots | Superseded 8 October by the authorized supplied Advanced physical p34, which expressly excludes Richese auctions from Ixian Technology. Richese cache/Black Market lots now open directly; the once-per-round Technology use remains for ordinary lots. The temporary per-lot decline scaffolding and its tests were deleted. | [Auction source boundary](RICHESE_AUCTION_RULES.md#13-september-saved-per-lot-technology-decline--superseded-8-october) |
|
||||
| Spice Banker battle spending | Secret own-spice commitment is separate from support; only the surviving selected disc gains strength. Core Battle Plan payment rules spend it on death/loss and waive it for a sole traitor-call winner. This payment composition is explicit; upper deferred-income custody remains pending. | [Source contract and prototype](SPICE_BANKER_RUNTIME.md) |
|
||||
| Smuggler shipping | Optional free companion is included in the actual total; apply Guild rounding to the reduced priced count. Ordinary Fremen reserves are on planet. The officially permitted owned No-Field plus a separate free force is now prototyped at the unchanged one-marker price. Legacy unstamped ordinary declarations keep their quoted full price. | [Ordinary contract](SMUGGLER_SHIPMENT.md), [No-Field combination](SMUGGLER_NO_FIELD.md) |
|
||||
| Sandmaster movement | One optional board spice per distinct entered territory on an explicit legal route; source-only repositioning earns none. Per-movement Hajr reset is a documented grammatical inference. Canceled moves earn nothing. Native Fremen worm rides collect optionally at the destination before faction arrival reactions, with no intermediate route. Multiple-pile collection remains guarded. | [Ground routes](SANDMASTER_MOVEMENT.md), [worm rides](SANDMASTER_WORM.md) |
|
||||
| Sandmaster movement | One optional board spice per distinct entered territory; source-only repositioning earns none. Per-movement Hajr reset is a documented grammatical inference. Canceled moves earn nothing. Native Fremen worm rides and physical Fremen Ambassador relocations collect optionally at the destination before arrival reactions, without an invented intermediate route. Multiple-pile and concealed-relocation collection remain guarded. | [Ground/direct relocation](SANDMASTER_MOVEMENT.md), [worm rides](SANDMASTER_WORM.md) |
|
||||
| Diplomat defense | With no actual defense, one physical Worthless card in either Battle Plan slot may copy the opponent's canonical base Shield or Snooper. The same native trained disc may protect itself. The public after-reveal/before-traitor choice and mandatory discard after traitor preemption are documented implementation inferences. Lower retreat and modified or multirole defenses remain guarded. | [Source and connected prototype](DIPLOMAT_DEFENSE.md) |
|
||||
| Bureaucrat payments | Once per phase, optionally divert two from an actual third-party payment of at least five; payer cost is unchanged. Bribes trigger at transfer to the recipient's shield, not later collection. Native income is offered after Karama allowance as an explicit outcome-based inference; split contributions, split recipients and remaining payment families need further integration. | [Source and connected boundary](BUREAUCRAT_PAYMENTS.md) |
|
||||
| Mentat question preview | The physical card says to show the card to the questioner, privately; it stays in hand and does not bind a plan. Ask before posture/faction powers. A uniform target response protects held/absent privacy but awaits approval as an exception to skipping forced confirmations; standard activation stays disabled. Empty hands skip with no fabricated answer. | [Source and preview](MENTAT_QUESTION.md) |
|
||||
|
||||
@@ -5,6 +5,35 @@ ground movement now connects the normal band through explicit routes, optional
|
||||
collection, player controls, all four AI profiles and saved continuation.
|
||||
This does not open public Leader Skills starts or publication.
|
||||
|
||||
## Physical Fremen Ambassador relocation — 8 October 2026
|
||||
|
||||
The Ambassador expressly relocates a board force group. The normal
|
||||
Sandmaster movement band now optionally collects one spice at its entered
|
||||
destination, before arrival reactions. No intervening route is implied.
|
||||
Moving between sectors of the same territory earns none. The existing
|
||||
living native trainer, supported-profile and unique positive pile rules
|
||||
remain; concealed-marker relocation is still separate.
|
||||
|
||||
`sandmasterDestinationCollection` is shared by this direct relocation and
|
||||
the existing worm adapter. Human controls expose a checked, optional
|
||||
destination collection choice; bots attach it only to an eligible physical
|
||||
relocation. Native quoting records the trainer and exact pile in
|
||||
`ambassadorSandmaster`, distinct from ordinary ground-route receipts.
|
||||
Suspended CHOAM movement revalidates that receipt through the original
|
||||
Ambassador event. Cancellation collects nothing; commit transfers one
|
||||
board spice once before the original arrival continuation.
|
||||
|
||||
Basic and Advanced direct smokes cover collection, decline, same-territory
|
||||
and multiple-pile rejection. A bounded owned-destination Easy candidate
|
||||
reaches the real relocation engine. The adjacent native Advanced worm
|
||||
still transfers one spice and moves four forces. In Chromium, live React
|
||||
controls ran against the native engine: Basic collection produced
|
||||
spice21/board1, Advanced opt-out spice20/board2, both with destination2
|
||||
forces, unchanged ordinary allowances and completed Ambassador events.
|
||||
This is an isolated component/engine smoke, not HTTP or deployed acceptance.
|
||||
No broad suite or recovery campaign was run.
|
||||
|
||||
|
||||
## Source contract
|
||||
|
||||
The physical card and [Jack Reda's designer walkthrough, 15:48–16:08](https://www.youtube.com/watch?v=XT_azRVLq_0&t=948s)
|
||||
@@ -70,9 +99,10 @@ awaited the human. This is targeted browser evidence, not a complete human game.
|
||||
Broad checkpoint results are recorded with the checkpoint. These checks do not certify every skill or combined module.
|
||||
|
||||
The [worm-ride follow-up](SANDMASTER_WORM.md) connects native Fremen destination
|
||||
collection, including saved arrival reactions. Remaining work includes other
|
||||
nonordinary relocation, special movement cards, expansion/module integration, multiple-pile adjudication and
|
||||
wider interaction and strategy acceptance. The lower battle band remains in
|
||||
collection, including saved arrival reactions. Physical Fremen Ambassador
|
||||
relocation is connected above. Other nonordinary relocations, special cards,
|
||||
concealed-marker collection, multiple-pile adjudication and wider interaction/
|
||||
strategy acceptance remain. The lower battle band is in
|
||||
[the battle-effects contract](LEADER_BATTLE_EFFECTS.md).
|
||||
|
||||
## 21 September: native HMS relocation eligibility
|
||||
|
||||
+3
-1
@@ -99,7 +99,7 @@ import {
|
||||
guildTransportCost,
|
||||
} from './bot-mobility';
|
||||
import { planetologistMovementModeSupported, planetologistLeader } from './planetologist-movement';
|
||||
import { sandmasterDefaultChoice } from './sandmaster-movement';
|
||||
import { sandmasterDefaultChoice, sandmasterDestinationCollection } from './sandmaster-movement';
|
||||
import { sandmasterWormCollection } from './sandmaster-worm';
|
||||
import { richeseCardDefinition } from './richese-cards';
|
||||
import { presenceAt } from './force-presence';
|
||||
@@ -418,6 +418,8 @@ function fremenAmbassadorMoves(g: GameView): Action[] {
|
||||
eliteForces,
|
||||
territory: destination.territory,
|
||||
sector: destination.sector,
|
||||
...(!marker && sandmasterDestinationCollection(g, me.id, source.territory, destination.territory, destination.sector)?.blocked === null
|
||||
? { sandmasterCollect: true } : {}),
|
||||
...(marker
|
||||
? { noField: { tokenId: marker.tokenId, event: marker.event } }
|
||||
: {}),
|
||||
|
||||
@@ -14167,6 +14167,7 @@ function validateMovementOrder(g: Game, p: Player, move: MovementOrder) {
|
||||
territory: move.to,
|
||||
sector: move.sector,
|
||||
fighters: move.wantsFighters,
|
||||
...(move.ambassadorSandmaster ? { sandmasterCollect: true } : {}),
|
||||
...(move.noField
|
||||
? {
|
||||
noField: {
|
||||
@@ -15758,6 +15759,14 @@ function ambassadorRelocationMovement(g: Game, player: string) {
|
||||
function commitAmbassadorRelocation(g: Game, move: FremenAmbassadorMove) {
|
||||
const { entry, beneficiary: p } = currentFremenAmbassador(g);
|
||||
validateAmbassadorRelocationArrival(g, move);
|
||||
if (move.ambassadorSandmaster) {
|
||||
const { key } = move.ambassadorSandmaster;
|
||||
g.spice[key]--;
|
||||
if (!g.spice[key]) delete g.spice[key];
|
||||
p.spice++;
|
||||
log(g, `${p.name}'s Sandmaster collected 1 spice at ${territory(move.to).name} during Fremen Ambassador relocation, before arrival reactions.`,
|
||||
{ faction: p.faction, name: 'Sandmaster collection' });
|
||||
}
|
||||
if (move.noField) {
|
||||
requireRule(
|
||||
p.noField && p.noFieldEvent === move.noField.event,
|
||||
@@ -27407,6 +27416,7 @@ type CompletedMovement = Pick<
|
||||
> & { noField: boolean };
|
||||
type MovementOrder = {
|
||||
sandmaster?: SandmasterMovement;
|
||||
ambassadorSandmaster?: FremenAmbassadorMove['ambassadorSandmaster'];
|
||||
source?: 'ambassador';
|
||||
ambassadorEvent?: string;
|
||||
player: string;
|
||||
|
||||
@@ -12,6 +12,7 @@ import { presenceAt } from './force-presence';
|
||||
import { territoryEntryBlock } from './occupancy';
|
||||
import { ecazOccupancyRelation } from './ecaz-occupy';
|
||||
import { homeworldNoFieldMovementBlock } from './homeworld-mobility';
|
||||
import { sandmasterDestinationCollection } from './sandmaster-movement';
|
||||
|
||||
export class FremenAmbassadorMoveError extends Error {
|
||||
constructor(message: string) {
|
||||
@@ -32,6 +33,7 @@ export type FremenAmbassadorMove = {
|
||||
wantsFighters: boolean;
|
||||
lockedTurn?: number;
|
||||
noField?: { tokenId: string; event: string; from: string };
|
||||
ambassadorSandmaster?: { leader: string; key: string; before: number };
|
||||
};
|
||||
export type FremenAmbassadorMovement = {
|
||||
sources: {
|
||||
@@ -326,6 +328,17 @@ export function quoteFremenAmbassadorMove(
|
||||
count((p.elites?.forces[target] ?? 0) + elite),
|
||||
'The resulting force quantity is invalid.',
|
||||
);
|
||||
requireMove(action.sandmasterCollect === undefined || typeof action.sandmasterCollect === 'boolean',
|
||||
'Choose whether to collect spice with Sandmaster.');
|
||||
let ambassadorSandmaster: FremenAmbassadorMove['ambassadorSandmaster'];
|
||||
if (action.sandmasterCollect) {
|
||||
requireMove(physical > 0 && !noField,
|
||||
'Sandmaster collection with concealed Ambassador relocation is still being integrated.');
|
||||
const quote = sandmasterDestinationCollection(g, p.id, origin, to, sector);
|
||||
requireMove(quote && !quote.blocked && quote.key,
|
||||
quote?.blocked ?? 'Sandmaster collection needs the living native trainer.');
|
||||
ambassadorSandmaster = { leader: quote.leader, key: quote.key, before: quote.before };
|
||||
}
|
||||
return {
|
||||
player: p.id,
|
||||
group,
|
||||
@@ -339,6 +352,7 @@ export function quoteFremenAmbassadorMove(
|
||||
wantsFighters,
|
||||
...(lockedTurn === undefined ? {} : { lockedTurn }),
|
||||
...(noField ? { noField } : {}),
|
||||
...(ambassadorSandmaster ? { ambassadorSandmaster } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+5
-5
@@ -4542,7 +4542,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Connected movement, shipping, payment and battle skills share their normal controls and legal choices. CHOAM cannot spend a card already committed to a sale or sell a Special already discarded after Planetologist battle use.',
|
||||
'Ghola may revive an eligible own leader while a declared sale waits. Finish the spent Ghola, optional private replacement draw or decline, and any required assignment before the original sale resumes. The replacement belongs to the actual revived leader.',
|
||||
'The sale resumes with its original card, price and response passes. Canceling that sale preserves the completed free revival and does not return the spent Ghola.',
|
||||
'Fresh Advanced CHOAM and/or Ixians plus classic tables use distinct required family decks and all fourteen skills. Auditor is excluded both at assignment and paid revival; original support income and bank-held allied funding remain distinct. Richese and further optional combinations retain separate boundaries. Remaining skill effects are unfinished; this prototype does not certify complete expansion rules or AI strength.',
|
||||
'Fresh Advanced CHOAM and/or Ixians plus classic tables use distinct required family decks and all fourteen skills. Auditor cannot be assigned a skill, including after its paid revival; original support income and bank-held allied funding remain distinct. Richese and further optional combinations retain separate boundaries. Remaining skill effects are unfinished; this prototype does not certify complete expansion rules or AI strength.',
|
||||
],
|
||||
related: ['leader-skills', 'choam-modules', 'implementation-checklist'],
|
||||
checklist: [
|
||||
@@ -4739,14 +4739,14 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Bureaucrat may redirect two spice from a payment of at least five between two other players, once per phase. The payer still pays in full; the recipient receives two less. Declining keeps the use available for a later payment. Paid bribes qualify when transferred, with the remaining share still unavailable until Mentat Pause. Ordinary auctions, Guild income, Richese sales and actual paid Tleilaxu revival income in supported Basic or bounded Advanced native skill profiles have connected payment paths. A separate free-revival award is not part of the paid price and stays with Tleilaxu. Split funding, gifts, other payment families and combined modules remain unfinished.',
|
||||
'Smuggler records the existing battle-territory spice when plans reveal, capped by the selected leader’s unmodified strength. If that leader survives, it collects automatically even when losing or no forces remain. A captive pays its controller. Pending spice cannot fund the plan. Collection precedes Sandmaster’s addition; a drained pile receives none. Modified strength, multiple positive piles and combined modules remain guarded. Older saved battles keep their original behavior.',
|
||||
'In classic and supported E1/E2 native Tech composition, actual Smuggler prices and normal Banker qualifying fees remain distinct from phase-end Tech income. Tech piles cannot fund their triggering action; Banker front-shield grants still wait for actual Mentat. Winning physical Suk rescue and Sandmaster board spice precede original played-card cleanup and mandatory winner token reward. One token transfers automatically, several require a selection, and no token skips without an invented decline. A later Face Dancer retains the original winner’s cards, bounty and Tech reward, kills the matching trainer without another bounty and replaces only actual survivors from real sources. Native Tleilaxu-only free revival excludes Axlotl; CHOAM initial income is not Charity. Richese’s free companion does not change its marker price or create the concealed counters early.',
|
||||
'Other special Sandmaster relocation remains missing. Normal Banker income has separate fresh classic or supported native source-to-Mentat previews with provisional original-earned-faction custody, not general activation. Diplomat retreat retains its documented conservative outcome order. Native Advanced foreign gholas/Face Dancers, skill-first Moritani assassination and ordinary-disc Ecaz skills follow their bounded contracts; shared-Duke assignment, combined Occupy skills and other combined modules remain unfinished. This prototype does not enable public module starts.',
|
||||
'Physical Fremen Ambassador relocation now offers optional Sandmaster destination collection before arrival reactions, without an invented route or ordinary movement use. Same-territory repositioning earns none; concealed relocation and other special movement still retain their own limits. Normal Banker income has separate fresh classic or supported native source-to-Mentat previews with provisional original-earned-faction custody, not general activation. Diplomat retreat retains its documented conservative outcome order. Native Advanced foreign gholas/Face Dancers, skill-first Moritani assassination and ordinary-disc Ecaz skills follow their bounded contracts; shared-Duke assignment, combined Occupy skills and other combined modules remain unfinished. This prototype does not enable public module starts.',
|
||||
...LEADER_SKILL_CARDS.map(card => `${card.name}. Normal: ${card.normal.join(' ')} Skilled battle: ${card.battle.join(' ')}`),
|
||||
],
|
||||
related: ['spice-banker-income', 'choam-leader-skills', 'ixian-leader-skills', 'tleilaxu-leader-skills', 'richese-leader-skills', 'ecaz-leader-skills', 'moritani-leader-skills', 'setup','choam-modules','implementation-checklist'],
|
||||
checklist: [
|
||||
{ area:'Implementation',status:'Partial',detail:'Exact fourteen-card custody, source-ordered setup, public capture, concealment, death and own revival connect five role bonuses, Planetologist movement/Special substitution and Suk Graduate casualty rescue. Rihani inspection/exchange and lower Mentat/Bureaucrat/Sandmaster effects are connected. Smuggler normal reserve shipping, the owned Richese No-Field companion, unmodified battle collection and optional Sandmaster ground-route and native Fremen worm-ride collection are connected. A separately opted-in Mentat preview connects private pre-plan questions and historical observations while ordinary activation awaits the uniform-response decision. Spice Banker lower spending and native Diplomat base Shield/Snooper copying are connected; skilled Diplomat retreat now transfers undialed normal/elite forces before losing casualties in supported battles. Normal Banker income connects only in the separate fresh classic or supported Basic native banker-income profile; other remaining bands and combined modules remain missing. Bureaucrat redirects supported third-party auction, shipment and bribe payments with exact saved once-per-phase use.',evidence:['game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/suk-graduate.ts','game/rihani-decipherer.ts','game/mentat-question.ts','game/bureaucrat-payment.ts','game/leader-skill-battle-board.ts','game/smuggler-shipment.ts','game/smuggler-no-field.ts','game/smuggler-battle.ts','game/spice-banker.ts','game/spice-banker-income.ts','game/sandmaster-movement.ts','game/sandmaster-worm.ts','game/diplomat-defense.ts','game/diplomat-retreat.ts','game/engine.ts'] },
|
||||
{ area:'Player controls',status:'Partial',detail:'Private card/leader selection, readable inspectors, public battle posture and revival draw/decline, Planetologist range/gather controls, Special weapon selection and Suk Graduate physical rescue choices are connected. Rihani has private history and separate keep-new/reveal-old controls; automatic battle effects need no confirmation. Smuggler has explicit ordinary shipping opt-out and No-Field companion opt-in with physical-versus-priced force counts, plus automatic surviving-leader battle collection with reveal-time guidance. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes, per-territory collection choices and an optional worm-destination checkbox. Diplomat offers a named committed Worthless choice or decline after public reveal, and a separate surviving-loser destination/normal/elite retreat or decline. Bureaucrat offers redirect or full payment with explicit recipient amounts and deferred bribe custody. Remaining effect controls are missing.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/rihani-decipherer.tsx','components/mentat-question.tsx','components/bureaucrat-payment.tsx','components/leader-skill-battle-guide.tsx','components/smuggler-shipment.tsx','components/spice-banker.tsx','components/sandmaster-movement.tsx','components/sandmaster-worm.tsx','components/diplomat-defense.tsx','components/diplomat-retreat.tsx','components/game-table.tsx'] },
|
||||
{ area:'AI',status:'Partial',detail:'All four profiles use private offers, legal visibility/revival actions and Planetologist movement/battle candidates and legal Suk Graduate rescues, Rihani exchanges, shared battle scoring, Smuggler reserve/owned No-Field shipments and unmodified collection plans, Sandmaster route and worm collection, funded Banker commitments and optional Diplomat copying from public revealed defenses or choosing legal losing-battle retreat. Bureaucrat uses the public payee alliance to redirect enemy income or preserve allied income. These are legal prototype paths; complete skill strategy and difficulty calibration remain unfinished.',evidence:['game/bots.ts'] },
|
||||
{ area:'Implementation',status:'Partial',detail:'Exact fourteen-card custody, source-ordered setup, public capture, concealment, death and own revival connect five role bonuses, Planetologist movement/Special substitution and Suk Graduate casualty rescue. Rihani inspection/exchange and lower Mentat/Bureaucrat/Sandmaster effects are connected. Smuggler normal reserve shipping, the owned Richese No-Field companion, unmodified battle collection and optional Sandmaster ground-route, native Fremen worm-ride and physical Fremen Ambassador destination collection are connected. A separately opted-in Mentat preview connects private pre-plan questions and historical observations while ordinary activation awaits the uniform-response decision. Spice Banker lower spending and native Diplomat base Shield/Snooper copying are connected; skilled Diplomat retreat now transfers undialed normal/elite forces before losing casualties in supported battles. Normal Banker income connects only in the separate fresh classic or supported Basic native banker-income profile; other remaining bands and combined modules remain missing. Bureaucrat redirects supported third-party auction, shipment and bribe payments with exact saved once-per-phase use.',evidence:['game/leader-skill-cards.ts','game/leader-skills.ts','game/leader-skill-combat.ts','game/suk-graduate.ts','game/rihani-decipherer.ts','game/mentat-question.ts','game/bureaucrat-payment.ts','game/leader-skill-battle-board.ts','game/smuggler-shipment.ts','game/smuggler-no-field.ts','game/smuggler-battle.ts','game/spice-banker.ts','game/spice-banker-income.ts','game/sandmaster-movement.ts','game/sandmaster-worm.ts','game/fremen-ambassador.ts','game/diplomat-defense.ts','game/diplomat-retreat.ts','game/engine.ts'] },
|
||||
{ area:'Player controls',status:'Partial',detail:'Private card/leader selection, readable inspectors, public battle posture and revival draw/decline, Planetologist range/gather controls, Special weapon selection and Suk Graduate physical rescue choices are connected. Rihani has private history and separate keep-new/reveal-old controls; automatic battle effects need no confirmation. Smuggler has explicit ordinary shipping opt-out and No-Field companion opt-in with physical-versus-priced force counts, plus automatic surviving-leader battle collection with reveal-time guidance. Banker exposes separate sealed spending and authorized inspection. Sandmaster exposes explicit legal routes, per-territory collection choices and optional worm/physical Ambassador destination checkboxes. Diplomat offers a named committed Worthless choice or decline after public reveal, and a separate surviving-loser destination/normal/elite retreat or decline. Bureaucrat offers redirect or full payment with explicit recipient amounts and deferred bribe custody. Remaining effect controls are missing.',evidence:['components/leader-skills.tsx','components/planetologist-movement.tsx','components/suk-graduate.tsx','components/rihani-decipherer.tsx','components/mentat-question.tsx','components/bureaucrat-payment.tsx','components/leader-skill-battle-guide.tsx','components/smuggler-shipment.tsx','components/spice-banker.tsx','components/sandmaster-movement.tsx','components/sandmaster-worm.tsx','components/ambassador-movement.tsx','components/diplomat-defense.tsx','components/diplomat-retreat.tsx','components/game-table.tsx'] },
|
||||
{ area:'AI',status:'Partial',detail:'All four profiles use private offers, legal visibility/revival actions and Planetologist movement/battle candidates and legal Suk Graduate rescues, Rihani exchanges, shared battle scoring, Smuggler reserve/owned No-Field shipments and unmodified collection plans, Sandmaster route, worm and physical Ambassador destination collection, funded Banker commitments and optional Diplomat copying from public revealed defenses or choosing legal losing-battle retreat. Bureaucrat uses the public payee alliance to redirect enemy income or preserve allied income. These are legal prototype paths; complete skill strategy and difficulty calibration remain unfinished.',evidence:['game/bots.ts'] },
|
||||
{ area:'Documentation',status:'Partial',detail:'All fourteen faces and common rules are recorded. Skilled capture follows public physical-card custody; replacement entitlement and other material timing interpretations remain explicit.',evidence:['docs/LEADER_SKILLS_RULES.md','docs/LEADER_SKILLS_CAPTURE.md','docs/LEADER_SKILLS_RUNTIME.md','docs/PLANETOLOGIST_RULES.md','docs/SUK_GRADUATE_RULES.md','docs/LEADER_BATTLE_EFFECTS.md','docs/MENTAT_QUESTION.md','docs/BUREAUCRAT_PAYMENTS.md','docs/SMUGGLER_SHIPMENT.md','docs/SPICE_BANKER_RUNTIME.md','docs/SANDMASTER_WORM.md','docs/DIPLOMAT_DEFENSE.md'] },
|
||||
{ area:'Verification',status:'Partial',detail:'Classic Homeworld Nexus optional modules add13 meaningful cases; affected242/242, types/lint/build pass. Twenty-two original six-seat games across15 families finish14233/14233 actions with375 JSON on one unchanged source tree. Human390px original typed invasion/zero-Suk and exact-three native return retain Kaitain14/Salusa5/visitor1; actual random Shrine entry, typed Maker ride and both-pile closing Moritani draw retain original armies. Native35-case and earlier source-qualified evidence remains in canonical logs. Native compositions, pending effects, full rules/combinations and deployed acceptance remain open.',evidence:['tests/homeworld-classic-nexus-skills-runtime.test.ts','tests/homeworld-classic-discovery-nexus-runtime.test.ts','tests/homeworld-native-e1e2-modules-runtime.test.ts','tests/homeworld-native-e3-modules-runtime.test.ts','tests/homeworld-classic-skills-runtime.test.ts','tests/homeworld-skills-discovery-runtime.test.ts','tests/homeworld-optional-modules-runtime.test.ts','tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/leader-battle-effects.test.ts','tests/suk-graduate.test.ts','tests/mentat-question.test.ts','tests/bureaucrat-payment.test.ts','tests/smuggler-battle.test.ts','tests/spice-banker.test.ts','tests/advanced-native-skills.test.ts','tests/fixture-advanced-native-skills.ts','tests/skills-tech-payments-runtime.test.ts','tests/skills-tech-battle-runtime.test.ts','tests/ix-choam-skills-tech-runtime.test.ts','tests/tleilaxu-skills-tech-runtime.test.ts','tests/richese-skills-tech-runtime.test.ts','tests/classic-skills-stronghold-runtime.test.ts','tests/native-skills-stronghold-runtime.test.ts','tests/ecaz-skills-modules-runtime.test.ts','tests/moritani-skills-modules-runtime.test.ts','docs/LEADER_SKILLS_RUNTIME.md','docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md'] },
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ordinaryLeaderSkillModeSupported } from './leader-skill-profile';
|
||||
import type { Action, Game, GameView } from './engine';
|
||||
import { GRAPH, location, mobileRouteDistance, splitLocation, MOBILE_LOCATION } from './board';
|
||||
import { GRAPH, location, mobileRouteDistance, splitLocation, MOBILE_LOCATION, validGameLocation } from './board';
|
||||
import { isAdvisor } from './advisors';
|
||||
import { strongholdPathBlocked } from './occupancy';
|
||||
import { botMovementRange } from './bot-mobility';
|
||||
@@ -55,6 +55,53 @@ export function sandmasterLeader(
|
||||
? leader.id
|
||||
: null;
|
||||
}
|
||||
|
||||
export type SandmasterDestinationCollection = {
|
||||
leader: string;
|
||||
key: string | null;
|
||||
before: number;
|
||||
blocked: string | null;
|
||||
};
|
||||
|
||||
/** Direct relocation enters only its destination, never an invented route. */
|
||||
export function sandmasterDestinationCollection(
|
||||
game: Game | GameView,
|
||||
player: string,
|
||||
origin: string,
|
||||
destination: string,
|
||||
sector: number,
|
||||
): SandmasterDestinationCollection | null {
|
||||
const leader = sandmasterLeader(game, player);
|
||||
if (!leader) return null;
|
||||
const blocked = (reason: string): SandmasterDestinationCollection =>
|
||||
({ leader, key: null, before: 0, blocked: reason });
|
||||
if (!sandmasterModeSupported(game))
|
||||
return blocked('Sandmaster collection with this configuration is still being integrated.');
|
||||
if (origin === destination || !validGameLocation(game, destination, sector) ||
|
||||
sector === game.storm)
|
||||
return blocked('Choose a different legal destination territory for Sandmaster collection.');
|
||||
let key: string | null = null;
|
||||
let before = 0;
|
||||
for (const pile in game.spice) {
|
||||
const at = splitLocation(pile);
|
||||
if (at.territory !== destination) continue;
|
||||
const amount = game.spice[pile];
|
||||
if (!validGameLocation(game, at.territory, at.sector) ||
|
||||
pile !== location(at.territory, at.sector) ||
|
||||
!Number.isSafeInteger(amount) || amount < 0)
|
||||
return blocked('Sandmaster needs a valid destination spice pile.');
|
||||
if (!amount) continue;
|
||||
if (key) return blocked('Sandmaster collection among multiple spice piles awaits its allocation ruling.');
|
||||
key = pile;
|
||||
before = amount;
|
||||
}
|
||||
if (!key) return blocked('There is no spice to collect at this destination.');
|
||||
const spice = game.players.find(p => p.id === player)?.spice;
|
||||
if (!Number.isSafeInteger(spice) || !Number.isSafeInteger(spice! + 1))
|
||||
return blocked('Sandmaster collection needs a valid spice balance.');
|
||||
return { leader, key, before, blocked: null };
|
||||
}
|
||||
|
||||
export function sandmasterPathBlocked(
|
||||
g: Game | GameView,
|
||||
player: string,
|
||||
|
||||
+5
-30
@@ -1,15 +1,8 @@
|
||||
import type { Game, GameView } from './engine';
|
||||
import { splitLocation, validGameLocation } from './board';
|
||||
import { faction } from './catalog';
|
||||
import { nativeExpansionLeaderSkillsProfile } from './leader-skill-profile';
|
||||
import { sandmasterLeader, sandmasterModeSupported } from './sandmaster-movement';
|
||||
|
||||
export type SandmasterWormCollection = {
|
||||
leader: string;
|
||||
key: string | null;
|
||||
before: number;
|
||||
blocked: string | null;
|
||||
};
|
||||
import { sandmasterDestinationCollection, sandmasterLeader, sandmasterModeSupported,
|
||||
type SandmasterDestinationCollection } from './sandmaster-movement';
|
||||
|
||||
/** A worm ride names a destination, not a traversed ground route. */
|
||||
export function sandmasterWormCollection(
|
||||
@@ -18,34 +11,16 @@ export function sandmasterWormCollection(
|
||||
destination: string,
|
||||
sector: number,
|
||||
decision: Game['decision'] | GameView['decision'] = game.decision,
|
||||
): SandmasterWormCollection | null {
|
||||
): SandmasterDestinationCollection | null {
|
||||
const leader = sandmasterLeader(game, player);
|
||||
if (!leader || game.phase !== 1 || decision?.kind !== 'wormRide' ||
|
||||
decision.player !== player ||
|
||||
game.players.find(p => p.id === player)?.faction !== 'fremen') return null;
|
||||
const blocked = (reason: string): SandmasterWormCollection =>
|
||||
const blocked = (reason: string): SandmasterDestinationCollection =>
|
||||
({ leader, key: null, before: 0, blocked: reason });
|
||||
if (!sandmasterModeSupported(game) || game.ecazTreachery ||
|
||||
!(nativeExpansionLeaderSkillsProfile(game) ||
|
||||
game.players.every(p => faction(p.faction).expansion === 'base')))
|
||||
return blocked('Sandmaster worm collection with other optional modules is still being integrated.');
|
||||
if (destination === decision.territory ||
|
||||
!validGameLocation(game, destination, sector) || sector === game.storm)
|
||||
return blocked('Choose a different legal destination for the worm ride.');
|
||||
const piles = Object.entries(game.spice)
|
||||
.filter(([key]) => splitLocation(key).territory === destination);
|
||||
if (piles.some(([key, amount]) => {
|
||||
const at = splitLocation(key);
|
||||
return !validGameLocation(game, at.territory, at.sector) ||
|
||||
key !== `${at.territory}:${at.sector}` || !Number.isSafeInteger(amount) || amount < 0;
|
||||
})) return blocked('Sandmaster needs a valid destination spice pile.');
|
||||
const positive = piles.filter(([, amount]) => amount > 0);
|
||||
if (positive.length > 1)
|
||||
return blocked('Sandmaster collection among multiple spice piles awaits its allocation ruling.');
|
||||
if (!positive.length) return blocked('There is no spice to collect at this destination.');
|
||||
const [key, before] = positive[0];
|
||||
const spice = game.players.find(p => p.id === player)?.spice;
|
||||
if (!Number.isSafeInteger(spice) || !Number.isSafeInteger(spice! + 1))
|
||||
return blocked('Sandmaster collection needs a valid spice balance.');
|
||||
return { leader, key, before, blocked: null };
|
||||
return sandmasterDestinationCollection(game, player, decision.territory, destination, sector);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from '../game/richese-no-field';
|
||||
import { createTerrorState, placeTerror } from '../game/moritani-terror';
|
||||
import { MOBILE_LOCATION, MOBILE_STRONGHOLD } from '../game/board';
|
||||
import { createLeaderSkills } from '../game/leader-skills';
|
||||
|
||||
const p = (g: Game, id: string) => g.players.find((p) => p.id === id)!;
|
||||
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
|
||||
@@ -144,6 +145,32 @@ function trigger(g: Game, beneficiary = 'a', copy = false) {
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
void test('physical Fremen Ambassador relocation collects optional Sandmaster spice without consuming ordinary allowances', () => {
|
||||
for (const collect of [false, true]) {
|
||||
const start = fixture();
|
||||
start.expansions = ['ecaz'];
|
||||
army(start, 'a', { 'red_chasm:7': 2 });
|
||||
start.spice = { 'hagga_basin:12': 2 };
|
||||
const skills = createLeaderSkills(() => 0.2);
|
||||
skills.deck = skills.deck.filter(skill => skill !== 'sandmaster');
|
||||
skills.assignments = [{ skill: 'sandmaster', owner: 'a', leader: p(start, 'a').leaders[0].id }];
|
||||
start.leaderSkills = skills;
|
||||
const pending = trigger(enter(start));
|
||||
const done = move(pending, {
|
||||
forces: { 'red_chasm:7': 2 }, territory: 'hagga_basin', sector: 12,
|
||||
sandmasterCollect: collect,
|
||||
});
|
||||
assert.equal(p(done, 'a').forces['hagga_basin:12'], 2);
|
||||
assert.equal(p(done, 'a').spice, collect ? 21 : 20);
|
||||
assert.equal(done.spice['hagga_basin:12'], collect ? 1 : 2);
|
||||
assert.equal(p(done, 'a').shipped, false);
|
||||
assert.equal(p(done, 'a').moved, 0);
|
||||
assert.equal(p(done, 'a').reserves, 18);
|
||||
assert.equal(p(done, 'a').tanks, 0);
|
||||
assert.equal(done.pendingAmbassador, null);
|
||||
}
|
||||
});
|
||||
function move(g: Game, extra: Omit<Action, 'type'>, actor = 'a') {
|
||||
return applyAction(g, actor, {
|
||||
type: 'decision',
|
||||
|
||||
@@ -13,8 +13,6 @@ void test('the ride control shows optional destination collection only for its e
|
||||
const html = (collect: boolean) => renderToStaticMarkup(createElement(SandmasterWormChoice, { quote, collect, onChange() {} }));
|
||||
assert.match(html(true), /checked=""/);
|
||||
assert.doesNotMatch(html(false), /checked=""/);
|
||||
assert.match(html(true), /Collect 1 spice/);
|
||||
assert.match(html(true), /source and intervening territories earn nothing/);
|
||||
assert.equal(renderToStaticMarkup(createElement(SandmasterWormChoice, {
|
||||
quote: sandmasterWormCollection(viewGame(g, 'h'), 'h', 'red_chasm', 7), collect: true, onChange() {},
|
||||
})), '');
|
||||
@@ -23,5 +21,4 @@ void test('the ride control shows optional destination collection only for its e
|
||||
quote: sandmasterWormCollection(viewGame(g, 'p'), 'p', 'red_chasm', 7), collect: true, onChange() {},
|
||||
}));
|
||||
assert.doesNotMatch(blocked, /type="checkbox"/);
|
||||
assert.match(blocked, /may still ride without collecting/);
|
||||
});
|
||||
Reference in new issue
Block a user