Support bribes during Basic movement decisions
Verify and publish checkpoint container / container (push) Successful in 10m49s
Verify and publish checkpoint container / container (push) Successful in 10m49s
This commit is contained in:
1 parent
9dc339e726
commit
ab7a947833
10 files changed
+435
-80
No files matched your search
@@ -0,0 +1,77 @@
|
||||
import { location, splitLocation, validLocation } from './board';
|
||||
import type { Game } from './engine';
|
||||
import { presenceAt } from './force-presence';
|
||||
|
||||
export type MovementPending = 'pendingFremenMove' | 'pendingIxMove' | 'pendingChoamMove';
|
||||
type MovementOrder = NonNullable<Game['pendingIxMove']>;
|
||||
|
||||
/** Identify a single, well-formed ordinary Basic movement awaiting its native reaction. */
|
||||
export function basicPendingMovement(g: Game): { key: MovementPending; player: string } | null {
|
||||
if (g.status !== 'playing' || g.advanced || g.phase !== 5 ||
|
||||
!Number.isSafeInteger(g.turn) || g.turn < 1 || g.phaseOpening || g.truthtrance ||
|
||||
g.ornithopter || g.choamMarket || !Array.isArray(g.players)) return null;
|
||||
|
||||
let key: MovementPending;
|
||||
let move: MovementOrder;
|
||||
const response = g.response;
|
||||
if (response?.kind === 'fremenMovement' && g.pendingFremenMove && !g.decision) {
|
||||
key = 'pendingFremenMove';
|
||||
move = g.pendingFremenMove.order;
|
||||
if (g.pendingFremenMove.turn !== g.turn) return null;
|
||||
} else if (response?.kind === 'ixMovement' && g.pendingIxMove && !g.decision) {
|
||||
key = 'pendingIxMove';
|
||||
move = g.pendingIxMove;
|
||||
} else if (!response && g.decision?.kind === 'choamMovement' && g.pendingChoamMove) {
|
||||
key = 'pendingChoamMove';
|
||||
move = g.pendingChoamMove;
|
||||
} else return null;
|
||||
|
||||
if (Object.entries(g).some(([name, value]) => name.startsWith('pending') && name !== key &&
|
||||
(Array.isArray(value) ? value.length > 0 : value !== null && value !== undefined && value !== false))) return null;
|
||||
if (!move || move.player !== g.active ||
|
||||
move.source || move.ambassadorEvent || move.noField || move.planetologist || move.sandmaster ||
|
||||
move.ambassadorSandmaster || move.ornithopterEvent || move.ornithopterRange || move.discoveryFlight ||
|
||||
!Array.isArray(move.group) || move.group.length === 0 || !move.eliteGroup ||
|
||||
typeof move.origin !== 'string' || typeof move.to !== 'string' ||
|
||||
!Number.isSafeInteger(move.sector) || !validLocation(move.to, move.sector) ||
|
||||
!Number.isSafeInteger(move.total) || move.total <= 0 ||
|
||||
!Number.isSafeInteger(move.elite) || move.elite < 0 || move.elite > move.total ||
|
||||
(move.origins && (move.origins.length !== 1 || move.origins[0] !== move.origin))) return null;
|
||||
const mover = g.players.find(player => player.id === move.player);
|
||||
if (!mover || !Number.isSafeInteger(mover.moved) || mover.moved < 0) return null;
|
||||
const seen = new Set<string>();
|
||||
let total = 0, elite = 0;
|
||||
for (const [source, amount] of move.group) {
|
||||
if (typeof source !== 'string' || seen.has(source) || splitLocation(source).territory !== move.origin ||
|
||||
!validLocation(splitLocation(source).territory, splitLocation(source).sector) ||
|
||||
!Number.isSafeInteger(amount) || amount <= 0 || amount > (mover.forces[source] ?? 0)) return null;
|
||||
seen.add(source);
|
||||
const selected = move.eliteGroup[source];
|
||||
if (!Number.isSafeInteger(selected) || selected < 0 || selected > amount ||
|
||||
selected > (mover.elites?.forces[source] ?? 0) ||
|
||||
amount - selected > (mover.forces[source] ?? 0) - (mover.elites?.forces[source] ?? 0)) return null;
|
||||
total += amount;
|
||||
elite += selected;
|
||||
}
|
||||
if (Object.keys(move.eliteGroup).some(source => !seen.has(source)) ||
|
||||
total !== move.total || elite !== move.elite) return null;
|
||||
|
||||
const result = { key, player: move.player };
|
||||
if (key === 'pendingChoamMove') {
|
||||
const decision = g.decision;
|
||||
const choam = g.players.find(player => player.faction === 'choam');
|
||||
return decision?.kind === 'choamMovement' && choam && presenceAt(choam, move.to) > 0 && decision.player === choam.id &&
|
||||
decision.mover === move.player && decision.territory === move.to &&
|
||||
decision.sector === move.sector && decision.amount === move.total ? result : null;
|
||||
}
|
||||
if (!response || response.owner !== move.player ||
|
||||
response.location !== location(move.to, move.sector) || response.amount !== move.total ||
|
||||
!Array.isArray(response.passed) || new Set(response.passed).size !== response.passed.length ||
|
||||
response.passed.some(id => !g.players.some(player => player.id === id))) return null;
|
||||
const keys = Object.keys(response).sort().join(',');
|
||||
if (key === 'pendingIxMove')
|
||||
return mover.faction === 'ixians' && keys === 'amount,elite,kind,location,owner,passed' &&
|
||||
response.elite === move.elite && move.elite > 0 ? result : null;
|
||||
return mover.faction === 'fremen' && keys === 'amount,kind,location,owner,passed' &&
|
||||
g.pendingFremenMove?.move === mover.moved ? result : null;
|
||||
}
|
||||
+9
-1
@@ -1,3 +1,4 @@
|
||||
import { basicPendingMovement } from './basic-pending-movement';
|
||||
import { createTwoPlayerControllers, validateTwoPlayerControllersLobby, validateTwoPlayerControllersComplete,
|
||||
addTwoPlayerControlledSeat, TwoPlayerControllersError, type TwoPlayerControllers } from './two-player-controllers';
|
||||
import { routeAuctionPayment, type AuctionPaymentLeg } from './auction-payment-routing';
|
||||
@@ -17492,6 +17493,7 @@ function basicBribeInterrupt(g: Game, p: Player): boolean {
|
||||
g.bureaucratPayments?.pending || g.battle || g.pendingRicheseGift || g.pendingShipment ||
|
||||
g.pendingGuildTransport || g.pendingArrivalOverlap || g.pendingAmbassador || g.pendingEmperorAuctionGift ||
|
||||
liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length ||
|
||||
liveTravelPromises(g.travelPromises ?? [], p.id, g.turn).length ||
|
||||
hasMovementPromises(g, p.id) || hasBribePromises(g, p.id) ||
|
||||
liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).length ||
|
||||
liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).length ||
|
||||
@@ -17509,6 +17511,7 @@ function basicBribeInterrupt(g: Game, p: Player): boolean {
|
||||
const classic = ['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen'];
|
||||
const nativeRoster = !g.expansions.length
|
||||
? g.players.every(seat => classic.includes(seat.faction)) : selectedPhaseModuleRoster(g);
|
||||
if (nativeRoster && !g.phaseOpening && !g.choamMarket && !g.ixAuction && basicPendingMovement(g)) return true;
|
||||
if (nativeRoster && !g.phaseOpening && !g.choamMarket && !g.ixAuction &&
|
||||
!Object.entries(g).some(([key, value]) => key.startsWith('pending') &&
|
||||
(Array.isArray(value) ? value.length > 0 : value !== null && value !== undefined && value !== false))) {
|
||||
@@ -33459,7 +33462,12 @@ function findShipmentCompletion(
|
||||
const action: Action = { type: 'ship', noField: token.id, event: p.noFieldEvent,
|
||||
territory: destination, sector };
|
||||
try {
|
||||
applyActionInner(g, p.id, action, 'movementPreparation');
|
||||
const trial = applyActionInner(g, p.id, action, 'movementPreparation');
|
||||
// The native declaration checks this after committing a detached
|
||||
// shipment. A witness must preserve the same physical movement.
|
||||
if (state.phase < 5) trial.movementRemaining = [p.id];
|
||||
if (hasMovementPromises(trial, p.id, true))
|
||||
reconcileGroundMovementPromises(trial, p.id, action, g.phase);
|
||||
return { early, actions: [...actions, action] };
|
||||
} catch (error) {
|
||||
if (!(error instanceof RuleError)) throw error;
|
||||
|
||||
+2
-2
@@ -296,7 +296,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
steps: [
|
||||
'Open Bribes beneath the table. Choose a non-allied faction and a positive whole amount, then use the named Pay button. The amount is public and leaves your supply immediately. A message or promise alone does not pay spice.',
|
||||
'The recipient keeps paid spice separately until the next Mentat opening. A payment after the current opening waits for next turn’s opening. Your incoming bribes are shown separately from your spendable supply; they cannot fund bids, shipments or another bribe yet.',
|
||||
'You may pay out of turn when no decision or response is pending, including during Mentat after opening collection. In supported Basic games with classic or selected native expansion factions, direct bribes can also interrupt a normal auction-payment decision, Atreides auction inspection or Fremen allied-revival grant. Supported Basic Ix and Moritani games also allow payments during the Amal opening or an Extortion choice. The original parent and its passes remain pending. You cannot bribe yourself or your ally, or while Inflation shows Double. Emperor gifts to allies use their own power controls.',
|
||||
'You may pay out of turn when no decision or response is pending, including during Mentat after opening collection. In supported Basic games with classic or selected native expansion factions, direct bribes can also interrupt a normal auction-payment decision, Atreides auction inspection, Fremen allied-revival grant, ordinary Fremen or Ixian movement response, or CHOAM movement decision. Supported Basic Ix and Moritani games also allow payments during the Amal opening or an Extortion choice. The original parent and its passes remain pending. You cannot bribe yourself or your ally, or while Inflation shows Double. Emperor gifts to allies use their own power controls.',
|
||||
'Your payment limit excludes reserved spending and preserves binding Truthtrance answers. If an alliance, pending decision or available supply changes, recheck the recipient and amount. A rejected payment does not change the game.',
|
||||
'In the Basic group preview, group allies are excluded from ordinary bribes and nonallied deals. Existing agreements remain recorded and can be released; a deferred bribe to a newly allied recipient stays pending while it is unavailable, subject to its original deadline.',
|
||||
'A qualifying Bureaucrat may divert two spice to the Bank without reducing the payer’s cost. The remainder stays deferred. Unsupported combinations remain unavailable and do not imply an official rule prohibition.',
|
||||
@@ -313,7 +313,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Public deal proposals, responses and mutual release, named bribe recipient, explicit payment, and structured current- or later-turn travel with public status and an owner-only native-action guide.', evidence: ['components/public-deals.tsx', 'components/travel-promises.tsx', 'components/bribes.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Passive receipt/collection, Bureaucrat responses and legal autopilot continuation of a previously accepted travel term work. Strategic bribe initiation and negotiation wait for feature completion.' },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Public deal consent, structured travel and payment, player-managed terms, escrow, restrictions and remaining boundaries are recorded.', evidence: ['docs/PUBLIC_DEALS.md', 'docs/BRIBE_CONTROLS.md', 'docs/MENTAT_BRIBES.md', 'tests/bribe-classic-auction-runtime.test.ts', 'tests/bribe-classic-response-runtime.test.ts', 'tests/bribe-classic-pending-controls.test.tsx', 'tests/public-deals-travel-runtime.test.ts', 'tests/public-deals-travel-controls.test.tsx'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Supported Basic pending opening and Extortion payments retain their original parent and deferred collection; the actual opening and next-turn collection have focused runtime and rendered-control coverage. Public deal consent, release, immediate and deferred native payments, authenticated visibility and legal bot continuation have focused coverage, including staged group and legacy records. Group travel has actual multi-seat consent, physical travel and release coverage. Original late Mentat/next-turn receipt journeys supplement existing bribe projection, budgets and Bureaucrat coverage. Full integrated acceptance remains open.', evidence: ['tests/bribe-pending-basic.test.ts', 'tests/bribe-pending-basic-controls.test.tsx', 'tests/coalition-bribe-deals.test.ts', 'tests/coalition-bribe-truth.test.ts', 'tests/public-deals.test.ts', 'tests/public-deals-engine.test.ts', 'tests/public-deals-multiparty-engine.test.ts', 'tests/public-deals-group-travel-runtime.test.ts', 'tests/public-deals-payment-engine.test.ts', 'tests/public-deals-deferred-runtime.test.ts', 'tests/public-deals-deferred-truthtrance.test.ts', 'tests/public-deals-deferred-charity.test.ts', 'tests/public-deals-deferred-income.test.ts', 'tests/public-deals-deferred-http.test.ts', 'tests/public-deals-controls.test.tsx', 'tests/public-deals-http.test.ts', 'tests/public-deals-multiparty-http.test.ts', 'tests/public-deals-payment-http.test.ts', 'tests/mentat-bribes-runtime.test.ts', 'tests/bribe-controls.test.tsx', 'tests/bribe-controls-http.test.ts'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Supported Basic movement responses, pending opening and Extortion payments retain their original parent and deferred collection; focused runtime cases cover native decisions and legal bot responses. The actual opening and next-turn collection also have runtime and rendered-control coverage. Public deal consent, release, immediate and deferred native payments, authenticated visibility and legal bot continuation have focused coverage, including staged group and legacy records. Group travel has actual multi-seat consent, physical travel and release coverage. Original late Mentat/next-turn receipt journeys supplement existing bribe projection, budgets and Bureaucrat coverage. Full integrated acceptance remains open.', evidence: ['tests/basic-bribe-movement-runtime.test.ts', 'tests/bribe-pending-basic.test.ts', 'tests/bribe-pending-basic-controls.test.tsx', 'tests/coalition-bribe-deals.test.ts', 'tests/coalition-bribe-truth.test.ts', 'tests/public-deals.test.ts', 'tests/public-deals-engine.test.ts', 'tests/public-deals-multiparty-engine.test.ts', 'tests/public-deals-group-travel-runtime.test.ts', 'tests/public-deals-payment-engine.test.ts', 'tests/public-deals-deferred-runtime.test.ts', 'tests/public-deals-deferred-truthtrance.test.ts', 'tests/public-deals-deferred-charity.test.ts', 'tests/public-deals-deferred-income.test.ts', 'tests/public-deals-deferred-http.test.ts', 'tests/public-deals-controls.test.tsx', 'tests/public-deals-http.test.ts', 'tests/public-deals-multiparty-http.test.ts', 'tests/public-deals-payment-http.test.ts', 'tests/mentat-bribes-runtime.test.ts', 'tests/bribe-controls.test.tsx', 'tests/bribe-controls-http.test.ts'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,85 +1,17 @@
|
||||
import { location, splitLocation, validLocation } from './board';
|
||||
import type { Game } from './engine';
|
||||
import { presenceAt } from './force-presence';
|
||||
|
||||
type MovementPending = 'pendingFremenMove' | 'pendingIxMove' | 'pendingChoamMove';
|
||||
type MovementOrder = NonNullable<Game['pendingIxMove']>;
|
||||
import { basicPendingMovement, type MovementPending } from './basic-pending-movement';
|
||||
|
||||
/**
|
||||
* Identify a single ordinary foreign movement awaiting its native prevention
|
||||
* response. Revealing Richese's already-present marker cannot add an occupying
|
||||
* faction, so another faction's declared route and destination remain legal.
|
||||
* The caller retains its own common profile and Truthtrance/commitment guards.
|
||||
* Revealing Richese's existing marker during a different player's ordinary
|
||||
* movement cannot add an occupying faction to the declared destination. The
|
||||
* caller retains its common profile and Truthtrance/commitment guards.
|
||||
*/
|
||||
export function richeseRevealForeignMovementPending(g: Game): MovementPending | null {
|
||||
if (g.status !== 'playing' || g.advanced || g.phase !== 5 ||
|
||||
!Number.isSafeInteger(g.turn) || g.turn < 1 || g.phaseOpening || g.truthtrance ||
|
||||
g.ornithopter || g.choamMarket || !Array.isArray(g.players)) return null;
|
||||
if (!Array.isArray(g.players)) return null;
|
||||
const richese = g.players.find(player => player.faction === 'richese');
|
||||
const deployed = richese?.noField?.deployed;
|
||||
if (!richese || !deployed || deployed.controller !== richese.id ||
|
||||
!deployed.location || !('territory' in deployed.location)) return null;
|
||||
|
||||
let key: MovementPending;
|
||||
let move: MovementOrder;
|
||||
const response = g.response;
|
||||
if (response?.kind === 'fremenMovement' && g.pendingFremenMove && !g.decision) {
|
||||
key = 'pendingFremenMove';
|
||||
move = g.pendingFremenMove.order;
|
||||
if (g.pendingFremenMove.turn !== g.turn) return null;
|
||||
} else if (response?.kind === 'ixMovement' && g.pendingIxMove && !g.decision) {
|
||||
key = 'pendingIxMove';
|
||||
move = g.pendingIxMove;
|
||||
} else if (!response && g.decision?.kind === 'choamMovement' && g.pendingChoamMove) {
|
||||
key = 'pendingChoamMove';
|
||||
move = g.pendingChoamMove;
|
||||
} else return null;
|
||||
|
||||
if (Object.entries(g).some(([name, value]) => name.startsWith('pending') && name !== key &&
|
||||
(Array.isArray(value) ? value.length > 0 : value !== null && value !== undefined && value !== false))) return null;
|
||||
if (!move || move.player === richese.id || move.player !== g.active ||
|
||||
move.source || move.ambassadorEvent || move.noField || move.planetologist || move.sandmaster ||
|
||||
move.ambassadorSandmaster || move.ornithopterEvent || move.ornithopterRange || move.discoveryFlight ||
|
||||
!Array.isArray(move.group) || move.group.length === 0 || !move.eliteGroup ||
|
||||
typeof move.origin !== 'string' || typeof move.to !== 'string' ||
|
||||
!Number.isSafeInteger(move.sector) || !validLocation(move.to, move.sector) ||
|
||||
!Number.isSafeInteger(move.total) || move.total <= 0 ||
|
||||
!Number.isSafeInteger(move.elite) || move.elite < 0 || move.elite > move.total ||
|
||||
(move.origins && (move.origins.length !== 1 || move.origins[0] !== move.origin))) return null;
|
||||
const mover = g.players.find(player => player.id === move.player);
|
||||
if (!mover || !Number.isSafeInteger(mover.moved) || mover.moved < 0) return null;
|
||||
const seen = new Set<string>();
|
||||
let total = 0, elite = 0;
|
||||
for (const [source, amount] of move.group) {
|
||||
if (typeof source !== 'string' || seen.has(source) || splitLocation(source).territory !== move.origin ||
|
||||
!validLocation(splitLocation(source).territory, splitLocation(source).sector) ||
|
||||
!Number.isSafeInteger(amount) || amount <= 0 || amount > (mover.forces[source] ?? 0)) return null;
|
||||
seen.add(source);
|
||||
const selected = move.eliteGroup[source];
|
||||
if (!Number.isSafeInteger(selected) || selected < 0 || selected > amount ||
|
||||
selected > (mover.elites?.forces[source] ?? 0) ||
|
||||
amount - selected > (mover.forces[source] ?? 0) - (mover.elites?.forces[source] ?? 0)) return null;
|
||||
total += amount;
|
||||
elite += selected;
|
||||
}
|
||||
if (Object.keys(move.eliteGroup).some(source => !seen.has(source)) ||
|
||||
total !== move.total || elite !== move.elite) return null;
|
||||
|
||||
if (key === 'pendingChoamMove') {
|
||||
const decision = g.decision;
|
||||
const choam = g.players.find(player => player.faction === 'choam');
|
||||
return decision?.kind === 'choamMovement' && choam && presenceAt(choam, move.to) > 0 && decision.player === choam.id &&
|
||||
decision.mover === move.player && decision.territory === move.to &&
|
||||
decision.sector === move.sector && decision.amount === move.total ? key : null;
|
||||
}
|
||||
if (!response || response.owner !== move.player ||
|
||||
response.location !== location(move.to, move.sector) || response.amount !== move.total ||
|
||||
!Array.isArray(response.passed) || new Set(response.passed).size !== response.passed.length ||
|
||||
response.passed.some(id => !g.players.some(player => player.id === id))) return null;
|
||||
const keys = Object.keys(response).sort().join(',');
|
||||
if (key === 'pendingIxMove')
|
||||
return mover.faction === 'ixians' && keys === 'amount,elite,kind,location,owner,passed' &&
|
||||
response.elite === move.elite && move.elite > 0 ? key : null;
|
||||
return mover.faction === 'fremen' && keys === 'amount,kind,location,owner,passed' &&
|
||||
g.pendingFremenMove?.move === mover.moved ? key : null;
|
||||
const movement = basicPendingMovement(g);
|
||||
return movement && movement.player !== richese.id ? movement.key : null;
|
||||
}
|
||||
Reference in new issue
Block a user