Expand Semuta and Richese Homeworld shipments
Verify and publish checkpoint container / container (push) Successful in 10m7s

This commit is contained in:
Quintine committed 2026-10-28 22:26:47 +11:00
1 parent 1ba942138d
commit 4d769af8c7
15 files changed
+626 -39

No files matched your search

+10 -7
View File
@@ -2605,7 +2605,7 @@ export type Game = {
moritaniAssassinatePreview?: true;
/** Explicit development-only neutral Semuta reaction profile. */
semutaPreview?: true;
/** Fresh Basic independent-card preview for Semuta after Recruits only. */
/** Fresh Basic independent-card Semuta preview; scoped per discard source. */
semutaRecruitsPreview?: true;
moritaniAssassinate?: MoritaniAssassinateState;
moritaniAssassinateCallEvents?: string[];
@@ -9520,7 +9520,10 @@ function semutaOfferSupported(
if (continuation.kind === 'thumperDiscard' &&
(g.summonedBeforeBlow || g.summonedWorm || g.wormRides.length > 0 ||
g.pendingFremenMove || g.pendingIxMove || g.pendingChoamMove)) return false;
return g.semutaPreview === true && g.status === 'playing' &&
return (g.semutaPreview === true || g.semutaRecruitsPreview === true &&
basicIndependentSemutaProfile(g) && continuation.kind === 'ordinaryCardDiscard' &&
['hajr', 'ghola', 'harvester', 'weather', 'atomics'].includes(continuation.effect)) &&
g.status === 'playing' &&
g.players.some(p => p.faction === 'richese') &&
entries.length > 0 &&
(ambassadorDiscard || continuation.kind === 'kaitainDiscard' || continuation.kind === 'tupileCleanupDiscard' ||
@@ -37304,15 +37307,15 @@ function homeworldShipmentQuote(g: Game, intent: HomeworldShipmentIntent & {rout
'Homeworld No-Field shipment currently supports native Homeworld play without optional modules.');
requireRule(!intent.route && !intent.guildSecretEvent && intent.sources && typeof intent.sources === 'object' &&
!Array.isArray(intent.sources) &&
(intent.noField.from === undefined ? Object.keys(intent.sources).length === 0 :
Object.keys(intent.sources).every(source => source === intent.noField!.from)) &&
Object.keys(intent.sources).every(source =>
source === (intent.noField!.from ?? 'homeworld:richese')) &&
p.noField && p.noFieldEvent === intent.noField.event &&
(intent.noField.from !== undefined || p.noFieldBlockedTurn !== g.turn),
'The original concealed Homeworld shipment is no longer available.');
const marker = intent.noField.from === undefined
? {...homeworldRule(() => quoteRicheseHomeworldMarkerShipment(context, g.homeworlds!.custody!,
{player:intent.player,destination:intent.destination,tokenId:intent.noField!.tokenId},p.noField!)),
state:g.homeworlds.custody, players:context.players, sources:[]}
? homeworldRule(() => quoteRicheseHomeworldMarkerShipment(context, g.homeworlds!.custody!,
{player:intent.player,destination:intent.destination,tokenId:intent.noField!.tokenId,
...(Object.hasOwn(intent.sources, 'homeworld:richese') ? {forces:intent.sources['homeworld:richese']} : {})},p.noField!))
: homeworldRule(() => quoteRicheseHomeworldMarkerTransfer(context, g.homeworlds!.custody!,
{player:intent.player,destination:intent.destination,tokenId:intent.noField!.tokenId,source:intent.noField!.from!,
...(Object.hasOwn(intent.sources, intent.noField!.from!) ? {forces:intent.sources[intent.noField!.from!]} : {})},p.noField!));
+10 -11
View File
@@ -22,9 +22,8 @@ export function homeworldName(view: GameView, id: string): string {
);
}
/** Owner-only marker quote. The face stays private; the amount and invoice are
* one regardless of denomination. Authoritative timing and payment remain in
* the normal Homeworld shipment action. */
/** Owner-only marker quote. The face stays private; the marker counts as one
* regardless of denomination, plus any selected physical companions. */
export function richeseHomeworldMarkerChoice(
view: GameView,
destination: string,
@@ -71,11 +70,10 @@ export function richeseHomeworldMarkerChoice(
}
try {
if (source === undefined) {
if (forces !== undefined) {
result.blocked = 'Physical forces may accompany only a deployed Homeworld No-Field.';
return result;
}
quoteRicheseHomeworldMarkerShipment(context, custody, { player: me.id, destination, tokenId }, info.private);
const quote = quoteRicheseHomeworldMarkerShipment(context, custody,
{ player: me.id, destination, tokenId, ...(forces === undefined ? {} : { forces }) }, info.private);
result.amount = quote.amount;
result.cost = quote.cost;
} else {
const quote = quoteRicheseHomeworldMarkerTransfer(context, custody,
{ player: me.id, destination, tokenId, source, ...(forces === undefined ? {} : { forces }) }, info.private);
@@ -92,8 +90,8 @@ export function richeseHomeworldMarkerChoice(
result.blocked = 'Your spice and selected allied contribution cannot pay this shipment.';
return result;
}
const sources = source !== undefined && forces && forces.normal + forces.elite > 0
? { [source]: forces } : {};
const sources = forces && forces.normal + forces.elite > 0
? { [source ?? 'homeworld:richese']: forces } : {};
result.action = { type: 'homeworldShip', event: option.event, destination, sources,
noField: tokenId, noFieldEvent: info.event, allyPayment, ...(source === undefined ? {} : { noFieldFrom: source }) };
} catch (error) {
@@ -412,7 +410,8 @@ export function homeworldShipmentActions(
Math.max(0, 1 - (me.spice ?? 0)), source);
if (quote.action) {
actions.push(quote.action);
const pool = source && view.homeworlds?.worlds?.find(world => world.id === source)?.forces[me.id];
const pool = source ? view.homeworlds?.worlds?.find(world => world.id === source)?.forces[me.id] :
{ normal: me.reserves - (me.elites?.reserves ?? 0), elite: me.elites?.reserves ?? 0 };
if (pool && (pool.normal > 0 || pool.elite > 0)) {
const forces = { normal: pool.normal > 0 ? 1 : 0, elite: pool.normal > 0 ? 0 : 1 };
const initial = richeseHomeworldMarkerChoice(view, target.world.id, token.id, 0, source, forces);
+2 -2
View File
@@ -1124,7 +1124,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Richese has three No-Fields, numbered zero, three and five. A concealed token counts as one force. Only one may be deployed on Arrakis, and consecutive own or allied shipments cannot use the same token.',
'A normal No-Field shipment pays the one-force price. The concealed token moves like a force. Before Battle, Richese may reveal it voluntarily; a token involved in battle is revealed with the Battle Plan. Revelation brings the indicated forces from reserves, limited by what remains there.',
'Provisional Guild transport treats your owned concealed marker as one billable force alongside any same-origin physical counters. Native Guild allies may cross-ship it through the normal permission and rate responses. The actual absent-Guild Secret Ally may cross-ship or return it face down, spending its Nexus once and paying the Bank. Cross keeps concealment; return creates no denomination-sized reserve deposit and retains the same-token next-shipment ban. Ordinary Guild allies cannot return. Select the marker separately from physical counters; ordinary movement remains available.',
'In supported native Homeworld games, you may provisionally ship one concealed No-Field from your reserves to a foreign non-allied Homeworld for the one-force price. On a later turn, you may ship that same concealed marker between distinct eligible foreign Homeworlds, alone or with selected physical Richese counters from the marker’s current World. Price the whole group as one plus those counters; a purchased Guild rate halves the total, rounding up once. The marker retains its identity and last-shipment history, and its value does not materialize until reveal, when available reserves are placed there. Only one marker may be deployed globally. Initial marker placement with companions, own-Homeworld placement or return, and optional-module combinations remain unavailable. The supplied revised rulebook permits World-to-World shipment (physical p. 22) and counts a concealed marker as one force for effects (physical p. 36), but does not explicitly resolve this combination; this is a provisional interpretation.',
'In supported native Homeworld games, you may provisionally ship one concealed No-Field from your reserves to a foreign non-allied Homeworld, alone or with physical forces from those reserves. On a later turn, you may ship that same concealed marker between distinct eligible foreign Homeworlds, alone or with selected physical Richese counters from the marker’s current World. Price the whole group as one plus those counters; a purchased Guild rate halves the total, rounding up once. The marker retains its identity and last-shipment history, and its value does not materialize until reveal, when available reserves are placed there. Only one marker may be deployed globally. Own-Homeworld placement or return and optional-module combinations remain unavailable. The supplied revised rulebook permits World-to-World shipment (physical p. 22) and counts a concealed marker as one force for effects (physical p. 36), but does not explicitly resolve this combination; this is a provisional interpretation.',
'With a living, uncaptured Planetologist, your own concealed marker may provisionally use either ordinary movement alternative. Extra range adds one, capped at three, for one origin. Gathering combines exactly two territories at one destination using each selected sector’s ordinary range; the marker can supply one origin alone or with physical companions, and physical counters supply the other. The alternatives do not stack. The marker stays concealed, including zero, and spends one movement without materializing reserves. Physical and Discovery flight use the provisional skill transport policy with original marker quotas; complete combinations remain unfinished.',
'The revealed token stays face up until another token is placed. Revealing it does not clear the previous-shipment restriction. An allied No-Field shipment reveals immediately and uses the ally’s reserves; an existing token must be revealed before another shipment.',
'Before an eligible ally uses its shipment, the game holds an offer opportunity for Richese. Richese may pass or privately propose a token, destination and payment. The ally accepts the exact offer and chooses its permitted regular/elite mix, or declines without payment. Success consumes the ally’s shipment, not Richese’s shipment or either player’s movement.',
@@ -6606,7 +6606,7 @@ export const RULE_TOPICS: RuleTopic[] = [
steps: [
'The separate three-card variant includes Recruits, Reinforcements and Harass & Withdraw. It does not require the Ecaz faction. A development preview includes all three physical cards before setup; Recruits, bounded Reinforcements and Harass & Withdraw controls are connected.',
'The holder may play Recruits from its Revival panel. Discard the card once, then all players use the increased rates while this Revival phase remains open, including players who had already marked themselves ready. Completed returns still count toward their turn totals.',
'In a fresh Basic game with Richese, classic factions, the CHOAM deck and these independent cards, Semuta Drug may claim Recruits immediately after another player discards it. The Recruits benefit still lasts for the turn and cannot be played again. This preview covers the Recruits discard only; other Semuta discard opportunities in this combination remain unfinished.',
'In a fresh Basic game with Richese, classic factions, the CHOAM deck and these independent cards, Semuta Drug may claim Recruits immediately after another player discards it. The Recruits benefit still lasts for the turn and cannot be played again. Used Hajr, Ghola, Harvester, Weather Control and Family Atomics can also be claimed; other Semuta discard opportunities in this combination remain unfinished.',
'Atreides rises from two free forces to four; Fremen from three to six; Guild from one to two. The ordinary force limit becomes seven. Native unlimited CHOAM and Tleilaxu allowances remain unlimited, and their free rates become zero and four. Paid prices and separate elite limits still apply.',
'Free prevention still prevents free returns. Prior paid ordinary returns now have a visibly provisional policy: preserve payment, deduct only recorded free returns from the doubled free allowance and all returns from the total cap. Two free plus one paid Atreides may take two more free without refund; this is not a numerical user ruling. Fremen may now grant after play under the same provisional current-rate policy, with its original Karama response settled before commitment. Missing/inconsistent ledgers, pending transactions and same-turn replay remain guarded.',
'Clean classic2–6 Basic/Advanced Homeworlds compose through the fresh independent36-card setup. Double current native free rates including low-population bonuses, within the seven-force total. Provisionally recheck population and recorded free use on each separate request: low Fremen may take7 together, but1 crossing high leaves5 further free and cannot buy a seventh without Tleilaxu. Original typed destinations and special-force limits stay. Other overlays/rosters, public activation and whole-mode acceptance remain unfinished.',
+24 -5
View File
@@ -9,6 +9,8 @@ export type RicheseHomeworldMarkerShipmentIntent = {
player: string;
destination: string;
tokenId: string;
/** Optional physical companions withdrawn from native Richese reserves. */
forces?: HomeworldForces;
};
export type RicheseHomeworldMarkerTransferIntent = RicheseHomeworldMarkerShipmentIntent & {
source: string;
@@ -21,10 +23,9 @@ function requireMarker(condition: unknown, message: string): asserts condition {
if (!condition) throw new HomeworldCustodyError(message);
}
/** Quote one concealed Richese marker as a single shipment, not a physical
* counter. The engine owns timing, payment, response windows and commitment.
* Nothing in the input state is mutated, and the marker's denomination never
* changes its invoice or causes reserve counters to move. */
/** A fresh concealed marker counts as one for the whole shipment invoice. Any
* physical companions are independently withdrawn from native Richese reserves;
* the marker's denomination never enters the price or physical custody. */
export function quoteRicheseHomeworldMarkerShipment(
context: HomeworldShipmentContext,
custody: HomeworldCustody,
@@ -33,9 +34,17 @@ export function quoteRicheseHomeworldMarkerShipment(
) {
// This validates all physical reserve and foreign garrison custody first.
const worlds = homeworldForceGroups(context, custody);
requireMarker(record(intent) && Object.keys(intent).sort().join(',') === 'destination,player,tokenId' &&
requireMarker(record(intent) &&
(Object.keys(intent).sort().join(',') === 'destination,player,tokenId' ||
Object.keys(intent).sort().join(',') === 'destination,forces,player,tokenId') &&
typeof intent.player === 'string' && typeof intent.destination === 'string' && typeof intent.tokenId === 'string',
'Choose a Richese player, one active Homeworld and one physical No-Field.');
if ('forces' in intent) {
requireMarker(record(intent.forces) && Object.keys(intent.forces).sort().join(',') === 'elite,normal' &&
Number.isSafeInteger(intent.forces.normal) && Number.isSafeInteger(intent.forces.elite) &&
intent.forces.normal >= 0 && intent.forces.elite >= 0,
'Choose a valid physical normal and special counter group.');
}
requireMarker(context.players.every(seat => seat.ally === null ||
typeof seat.ally === 'string' && seat.ally !== seat.id && context.players.some(other => other.id === seat.ally)),
'Homeworld shipment needs valid seated alliance identities.');
@@ -60,6 +69,16 @@ export function quoteRicheseHomeworldMarkerShipment(
} catch (error) {
throw new HomeworldCustodyError(error instanceof Error ? error.message : 'Invalid No-Field shipment.');
}
if (intent.forces && intent.forces.normal + intent.forces.elite > 0) {
const physical = quoteHomeworldShipment(context, custody, {
player: player.id, destination: destination.id, sources: { [source.id]: intent.forces },
});
const amount = 1 + physical.amount;
return { player: player.id, tokenId: intent.tokenId, source: source.id,
destination: destination.id, amount, elite: physical.elite,
cost: context.purchasedGuildRate ? Math.ceil(amount / 2) : amount, noField: deployed,
state: physical.state, players: physical.players, sources: physical.sources };
}
return {
player: player.id,
tokenId: intent.tokenId,