Support sector-specific Truthtrance shipments
This commit is contained in:
1 parent
8006e53580
commit
536d334543
27 files changed
+1121
-71
No files matched your search
@@ -2,9 +2,11 @@
|
||||
import { TERRITORIES } from '@/game/board';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
export type ShipmentClauseInput = { territory: string; minimum: number };
|
||||
export type ShipmentClauseInput = { territory: string; minimum: number; sector?: number };
|
||||
export function invalidShipmentClause(claim: ShipmentClauseInput) {
|
||||
return !Number.isSafeInteger(claim.minimum) || claim.minimum < 1 || claim.minimum > 20;
|
||||
return !Number.isSafeInteger(claim.minimum) || claim.minimum < 1 || claim.minimum > 20 ||
|
||||
claim.sector !== undefined && (!Number.isSafeInteger(claim.sector) ||
|
||||
!TERRITORIES.find(destination => destination.id === claim.territory)?.sectors.includes(claim.sector));
|
||||
}
|
||||
|
||||
/** Destination and physical minimum shared by flat and nested question builders. */
|
||||
@@ -13,12 +15,36 @@ export function ShipmentClauseFields({ id, value, onChange }: {
|
||||
value: ShipmentClauseInput;
|
||||
onChange: (claim: ShipmentClauseInput) => void;
|
||||
}) {
|
||||
const printed = TERRITORIES.find(destination => destination.id === value.territory);
|
||||
// Mixed-shipment leaves have their own `kind` and remain on their existing
|
||||
// territory-only parser until that separate question family is integrated.
|
||||
const allowSector = !('kind' in value);
|
||||
return <>
|
||||
<label htmlFor={`${id}-territory`}>This-turn shipment destination</label>
|
||||
<select id={`${id}-territory`} value={value.territory}
|
||||
onChange={event => onChange({ ...value, territory: event.target.value })}>
|
||||
onChange={event => {
|
||||
const next = { ...value, territory: event.target.value };
|
||||
delete next.sector;
|
||||
onChange(next);
|
||||
}}>
|
||||
{TERRITORIES.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
|
||||
</select>
|
||||
{allowSector && <>
|
||||
<label htmlFor={`${id}-sector`}>Shipment sector</label>
|
||||
<select id={`${id}-sector`} value={value.sector === undefined ? '' : String(value.sector)}
|
||||
aria-invalid={value.sector !== undefined && !printed?.sectors.includes(value.sector)} onChange={event => {
|
||||
const selected = event.currentTarget.value;
|
||||
if (selected === '') {
|
||||
const next = { ...value }; delete next.sector; onChange(next);
|
||||
} else {
|
||||
const sector = Number(selected);
|
||||
if (printed?.sectors.includes(sector)) onChange({ ...value, sector });
|
||||
}
|
||||
}}>
|
||||
<option value="">Any sector in this territory</option>
|
||||
{printed?.sectors.map(sector => <option key={sector} value={sector}>Sector {sector}</option>)}
|
||||
</select>
|
||||
</>}
|
||||
<label htmlFor={`${id}-minimum`}>Minimum physical forces from reserves</label>
|
||||
<Input id={`${id}-minimum`} type="number" min={1} max={20} step={1} required
|
||||
value={Number.isNaN(value.minimum) ? '' : value.minimum}
|
||||
|
||||
@@ -14,6 +14,7 @@ const config: MixedActionConfig<TravelActionClaim> = {
|
||||
actionLabel: 'shipment or ground-movement condition',
|
||||
actionTiming: 'Future shipment or ground move · this turn',
|
||||
actionHelp: <>Shipment conditions concern your single future original reserve shipment after the answer.
|
||||
Choose any sector or one exact printed sector in that territory; only that sector satisfies an exact condition.
|
||||
Ground-movement conditions concern one actual future ground move before your Movement turn ends this round.
|
||||
Count physical forces in one action; smaller moves never add together. Earlier actions do not count.
|
||||
OR keeps legal shipment and movement alternatives available. No makes the whole expression false.
|
||||
@@ -28,7 +29,8 @@ const config: MixedActionConfig<TravelActionClaim> = {
|
||||
<label htmlFor={`${id}-action`}>Future action</label>
|
||||
<select id={`${id}-action`} value={value.kind} disabled={disabled} onChange={event => {
|
||||
const kind = event.currentTarget.value;
|
||||
if (kind === 'ship' || kind === 'move') onChange({ ...value, kind });
|
||||
if (kind === 'ship') onChange({ ...value, kind });
|
||||
else if (kind === 'move') onChange({ kind, territory: value.territory, minimum: value.minimum });
|
||||
}}>
|
||||
<option value="ship">Ship from reserves</option>
|
||||
<option value="move">Make ground movement</option>
|
||||
@@ -36,10 +38,32 @@ const config: MixedActionConfig<TravelActionClaim> = {
|
||||
<label htmlFor={`${id}-territory`}>{value.kind === 'ship' ? 'Reserve-shipment destination' : 'Ground-movement destination'}</label>
|
||||
<select id={`${id}-territory`} value={TERRITORIES.some(destination => destination.id === value.territory) ? value.territory : ''}
|
||||
disabled={disabled} aria-invalid={!TERRITORIES.some(destination => destination.id === value.territory)}
|
||||
onChange={event => onChange({ ...value, territory: event.currentTarget.value })}>
|
||||
onChange={event => {
|
||||
const destination = event.currentTarget.value;
|
||||
if (value.kind === 'ship') onChange({ kind: 'ship', territory: destination, minimum: value.minimum });
|
||||
else onChange({ ...value, territory: destination });
|
||||
}}>
|
||||
<option value="">Choose a printed Arrakis territory</option>
|
||||
{TERRITORIES.map(destination => <option key={destination.id} value={destination.id}>{destination.name}</option>)}
|
||||
</select>
|
||||
{value.kind === 'ship' && (() => {
|
||||
const printed = TERRITORIES.find(destination => destination.id === value.territory);
|
||||
return <>
|
||||
<label htmlFor={`${id}-sector`}>Shipment sector</label>
|
||||
<select id={`${id}-sector`} disabled={disabled} value={value.sector === undefined ? '' : String(value.sector)}
|
||||
aria-invalid={value.sector !== undefined && !printed?.sectors.includes(value.sector)} onChange={event => {
|
||||
const selected = event.currentTarget.value;
|
||||
if (selected === '') onChange({ kind: 'ship', territory: value.territory, minimum: value.minimum });
|
||||
else {
|
||||
const sector = Number(selected);
|
||||
if (printed?.sectors.includes(sector)) onChange({ ...value, sector });
|
||||
}
|
||||
}}>
|
||||
<option value="">Any sector in this territory</option>
|
||||
{printed?.sectors.map(sector => <option key={sector} value={sector}>Sector {sector}</option>)}
|
||||
</select>
|
||||
</>;
|
||||
})()}
|
||||
<label htmlFor={`${id}-minimum`}>Minimum physical forces in one {value.kind === 'ship' ? 'reserve shipment' : 'ground move'}</label>
|
||||
<Input id={`${id}-minimum`} type="number" min={1} max={20} step={1} required disabled={disabled}
|
||||
value={Number.isNaN(value.minimum) ? '' : value.minimum}
|
||||
|
||||
@@ -117,7 +117,8 @@ const config: MixedActionConfig<TurnActionLeaf> = {
|
||||
Amal conditions count an actual accepted future printed play in this original phase opening.
|
||||
Earlier plays, disposal, other roles and recovery alone do not count. The condition closes with this opening.
|
||||
Amal halves available cash before ordinary payments become legal; preserve enough remaining cash for any payment condition.
|
||||
Shipment conditions concern the single future original reserve shipment after the answer.
|
||||
Shipment conditions concern the single future original reserve shipment after the answer. Choose any sector or one exact printed sector;
|
||||
a different sector in the same territory does not satisfy an exact-sector condition.
|
||||
Ground-movement conditions use the maximum physical forces in one actual future move to that destination; smaller moves never add together.
|
||||
Bid conditions close with their named ordinary lot; Revival conditions close with your native Revival opportunity.
|
||||
Shipment and movement close at your own Movement turn end, while payment conditions last until the actual turn ends.
|
||||
@@ -429,10 +430,34 @@ const config: MixedActionConfig<TurnActionLeaf> = {
|
||||
<label htmlFor={`${id}-territory`}>{value.kind === 'ship' ? 'Reserve-shipment destination' : 'Ground-movement destination'}</label>
|
||||
<select id={`${id}-territory`} value={TERRITORIES.some(destination => destination.id === value.territory) ? value.territory : ''}
|
||||
disabled={disabled} aria-invalid={!TERRITORIES.some(destination => destination.id === value.territory)}
|
||||
onChange={event => onChange({ ...value, territory: event.currentTarget.value })}>
|
||||
onChange={event => {
|
||||
const destination = event.currentTarget.value;
|
||||
// An exact sector belongs to its printed territory. Selecting a
|
||||
// different territory returns to the explicit any-sector choice.
|
||||
if (value.kind === 'ship') onChange({ kind: 'ship', territory: destination, minimum: value.minimum });
|
||||
else onChange({ ...value, territory: destination });
|
||||
}}>
|
||||
<option value="">Choose a printed Arrakis territory</option>
|
||||
{TERRITORIES.map(destination => <option key={destination.id} value={destination.id}>{destination.name}</option>)}
|
||||
</select>
|
||||
{value.kind === 'ship' && (() => {
|
||||
const printed = TERRITORIES.find(destination => destination.id === value.territory);
|
||||
return <>
|
||||
<label htmlFor={`${id}-sector`}>Shipment sector</label>
|
||||
<select id={`${id}-sector`} disabled={disabled} value={value.sector === undefined ? '' : String(value.sector)}
|
||||
aria-invalid={value.sector !== undefined && !printed?.sectors.includes(value.sector)} onChange={event => {
|
||||
const chosen = event.currentTarget.value;
|
||||
if (chosen === '') onChange({ kind: 'ship', territory: value.territory, minimum: value.minimum });
|
||||
else {
|
||||
const sector = Number(chosen);
|
||||
if (printed?.sectors.includes(sector)) onChange({ ...value, sector });
|
||||
}
|
||||
}}>
|
||||
<option value="">Any sector in this territory</option>
|
||||
{printed?.sectors.map(sector => <option key={sector} value={sector}>Sector {sector}</option>)}
|
||||
</select>
|
||||
</>;
|
||||
})()}
|
||||
</>}
|
||||
{'minimum' in value && <>
|
||||
<label htmlFor={`${id}-minimum`}>{value.kind === 'payment' ? 'Minimum future ordinary payments in spice'
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import type { Action, GameView, Plan } from '@/game/engine';
|
||||
import { TERRITORIES } from '@/game/board';
|
||||
import { bribeAction } from '@/game/bribe-options';
|
||||
import { alliancePromiseModeSupported } from '@/game/alliance-promises';
|
||||
import { nativePromiseScopeSupported } from '@/game/native-promise-scope';
|
||||
@@ -39,6 +40,9 @@ const namesWindow = (mixed: MixedActionExpression<TurnActionLeaf>, effect: Windo
|
||||
const namesStormDial = (mixed: MixedActionExpression<TurnActionLeaf>, source: string): boolean =>
|
||||
mixed.kind === 'action' ? mixed.action.kind === 'stormDial' && mixed.action.source === source
|
||||
: mixed.kind !== 'fact' && mixed.terms.some(term => namesStormDial(term, source));
|
||||
const namesSectorShipment = (mixed: MixedActionExpression<TurnActionLeaf>): boolean =>
|
||||
mixed.kind === 'action' ? mixed.action.kind === 'ship' && mixed.action.sector !== undefined
|
||||
: mixed.kind !== 'fact' && mixed.terms.some(namesSectorShipment);
|
||||
const namesBoxPlay = (mixed: MixedActionExpression<TurnActionLeaf>): boolean => mixed.kind === 'action'
|
||||
? mixed.action.kind === 'cardPlay' && mixed.action.effect === 'nullentropyBox'
|
||||
: mixed.kind !== 'fact' && mixed.terms.some(namesBoxPlay);
|
||||
@@ -84,6 +88,14 @@ export function TurnActionGuideButton(props: TurnActionProps) {
|
||||
const box = game.nullentropy, search = box?.search;
|
||||
const windowPromise = game.turnActionPromises.find(promise => promise.event === event && promise.player === actor && promise.turn === turn);
|
||||
const windowQuestion = windowPromise && JSON.stringify(windowPromise.mixed);
|
||||
const sectorShipmentGuide = action.type === 'ship' && !!windowPromise && namesSectorShipment(windowPromise.mixed);
|
||||
const shipmentSource = (current: GameView) => {
|
||||
const owner = current.players.find(player => player.id === actor);
|
||||
return JSON.stringify({ active: current.active, remaining: current.movementRemaining, storm: current.storm,
|
||||
owner: owner && { reserves: owner.reserves, spice: owner.spice, shipped: owner.shipped },
|
||||
occupants: current.players.map(player => ({ id: player.id, ally: player.ally, forces: player.forces })) });
|
||||
};
|
||||
const shipmentSignature = sectorShipmentGuide ? shipmentSource(game) : null;
|
||||
const stormDialSource = action.type === 'stormDial' ? nativeStormDialOpportunity(game, actor) : null;
|
||||
const semutaAction = action.type === 'semutaCommit' || action.type === 'semutaSelect' || action.type === 'semutaPass';
|
||||
const semuta = semutaAction ? game.semutaReaction : null;
|
||||
@@ -415,6 +427,13 @@ export function TurnActionGuideButton(props: TurnActionProps) {
|
||||
!current.ready.includes(actor) && me.charityClaimed === false && current.charity === charity &&
|
||||
charitySource(current) === charitySignature && current.charity.incomePending === false &&
|
||||
Number.isSafeInteger(current.charity.amount) && current.charity.amount > 0;
|
||||
const currentSectorShipment = sectorShipmentGuide && !!me && phase === 5 && current.active === actor &&
|
||||
!!windowPromise && current.turnActionPromises.includes(windowPromise) && JSON.stringify(windowPromise.mixed) === windowQuestion &&
|
||||
shipmentSource(current) === shipmentSignature && me.shipped !== true &&
|
||||
typeof action.territory === 'string' && typeof action.sector === 'number' && Number.isSafeInteger(action.sector) &&
|
||||
TERRITORIES.some(territory => territory.id === action.territory && territory.sectors.includes(action.sector as number)) &&
|
||||
typeof action.amount === 'number' && Number.isSafeInteger(action.amount) && action.amount > 0 &&
|
||||
typeof me.reserves === 'number' && action.amount <= me.reserves;
|
||||
const currentStormDial = action.type === 'stormDial' && !!stormDialSource && !!windowPromise &&
|
||||
current.turnActionPromises.includes(windowPromise) && JSON.stringify(windowPromise.mixed) === windowQuestion &&
|
||||
namesStormDial(windowPromise.mixed, stormDialSource.source) &&
|
||||
@@ -437,7 +456,7 @@ export function TurnActionGuideButton(props: TurnActionProps) {
|
||||
Number.isSafeInteger(phase) && phase >= 0 && phase <= 8 && current.phase === phase && current.turn === turn && current.me === actor &&
|
||||
current.turnActionGuide === guide && guide.event === event && guide.action === action &&
|
||||
action.turnActionPromiseEvent === event && action.turnActionPromisePhase === phase && JSON.stringify(action) === signature && opportunity && movementPacket &&
|
||||
(!printedBattleCancelAction || printedWindow) &&
|
||||
(!printedBattleCancelAction || printedWindow) && (!sectorShipmentGuide || currentSectorShipment) &&
|
||||
current.turnActionPromises.some(promise => promise.event === event && promise.player === actor && promise.turn === turn &&
|
||||
!promise.fulfilled && !promise.released) &&
|
||||
(!amalReadyAction || openingReady) &&
|
||||
@@ -600,7 +619,8 @@ export function TurnActionPromises(props: TurnActionProps) {
|
||||
Amal conditions count an actual accepted future printed play in this original phase opening.
|
||||
Earlier plays, disposal, other roles and recovery alone do not count. The condition closes with this opening.
|
||||
Amal halves available cash before ordinary payments become legal; preserve enough remaining cash for any payment condition.
|
||||
Shipment conditions concern one future original reserve shipment. Ground-movement conditions count the maximum physical forces in one actual future move,
|
||||
Shipment conditions concern one future original reserve shipment. An optional exact sector counts only that printed sector of the named territory.
|
||||
Ground-movement conditions count the maximum physical forces in one actual future move,
|
||||
never the sum of smaller moves. Earlier shipment and movement do not count.
|
||||
Bid conditions close with their named ordinary lot; Revival conditions close with your native Revival opportunity.
|
||||
Shipment and movement close at your own Movement turn end; ordinary payments remain possible until the actual turn ends.
|
||||
|
||||
Reference in new issue
Block a user