Connect Tleilaxu income to committed Ambassador revivals

Positive free force/leader returns share the existing beneficiary/turn free-income ledger under the explicit provisional source composition. Actual Ghola remains separately rewarded. Decline earns nothing. Bind a distinct revival-income response to the completed physical return and original Ambassador; allow/cancel finishes once and cancellation preserves the return and spent entitlement.

Add persistent preselection and income guidance. Admit the bare Basic Ecaz+Ix native Tleilaxu composition through existing coalition/Face Dance mechanics, retaining unsupported faction/module guards and existing start/battle assertions.

Verify focused tests, types/lint/build, four original public Basic native/copy allow/cancel programmes, actual control rendering and eight existing legal policies. Exact frozen evidence and boundaries are in docs/TLEILAXU_AMBASSADOR_RULES.md. Full rules/modes, wider combinations, assurance and deployed acceptance remain unfinished. No strategic calibration, saved-room operation, reset, restart or deployment.
This commit is contained in:
Quintine committed 2026-10-24 13:07:08 +11:00
1 parent f9e204c301
commit 6d30e7c5e1
15 files changed
+1125 -30

No files matched your search

+82 -7
View File
@@ -16,6 +16,8 @@ import {
import { quoteSukPhysicalRescue, quoteSukRescue, SukPhysicalRescueError, sukReceiptSignature, sukRescueOptions, type SukForceGroup, type SukPhysicalRescueQuote, type SukPhysicalRescueReceipt, type SukReserveDestinations, type SukRescueOption, type SukRescueReceipt } from './suk-graduate';
import { quoteTleilaxuAmbassadorForces, TleilaxuAmbassadorForceError } from './tleilaxu-ambassador-forces';
import { tleilaxuAmbassadorLeaderChoices, tleilaxuAmbassadorLeaderSelection } from './tleilaxu-ambassador-leaders';
import { createAmbassadorRevivalReceipt, validateAmbassadorRevivalReceipt, AmbassadorRevivalIncomeError,
type AmbassadorRevivalReceipt, type AmbassadorRevivalReturn } from './tleilaxu-ambassador-income';
import { leaderSkillStrongholdCount, sandmasterVictorySpice, sandmasterVictoryPiles, type SandmasterVictorySpiceOffer } from './leader-skill-battle-board';
import { beginRihani, chooseRihaniDraw, finishRihani, validateRihani, type RihaniReceipt, type RihaniSkill } from './rihani-decipherer';
import { planetologistMovementModeSupported, planetologistLeader, planetologistRange, selectedOriginElites, groundMovementRange, planetologistGatherAdvisorEntry, advisorFlipMovementBlocked, type PlanetologistMovement } from './planetologist-movement';
@@ -1059,7 +1061,7 @@ export type ResponseWindow = {
occupiedPayment?: { event: string; contributions: { payer: string; amount: number }[] };
/** Bank-paid portion of a frozen Homeworld charity claim. */
charityHomeworld?: number;
source?: 'ambassador';
source?: 'ambassador' | 'ambassadorRevival';
intent?: string;
advisors?: boolean;
/** Saved exact ordinary Guild-rate response; not the independent Karama rate. */
@@ -1770,6 +1772,8 @@ export type Game = {
stage: 'income' | 'bonus';
nexusEvent?: string;
};
/** The free return is committed; only its bank income remains answerable. */
revivalReceipt?: AmbassadorRevivalReceipt;
beneficiary?: string;
effect?: AmbassadorEffect;
copyChoices: AmbassadorEffect[];
@@ -10754,6 +10758,15 @@ export function initializeFactionExpansionsGameForAudit(state: Game): Game {
return initialized;
}
function ecazOccupyCompositionSupported(g: Game): boolean {
// Native Tleilaxu's ordinary Basic armies and Face Dance already use the
// same coalition outcome. Admit their bare selected decks without opening
// unrelated native factions or optional-module compositions.
const basicTleilaxu = g.advanced === false && Array.isArray(g.expansions) && g.expansions.length === 2 &&
g.expansions.includes('ecaz') && g.expansions.includes('ix') &&
!!byFaction(g, 'tleilaxu') &&
g.players.every(p => p.faction === 'ecaz' || p.faction === 'moritani' ||
p.faction === 'tleilaxu' || CLASSIC_FACTIONS[p.faction]) &&
!g.ecazTreachery && !g.techTokens && !g.strongholdCards;
if ((independentNexusProfile(g) || independentDiscoveryProfile(g)) && byFaction(g, 'ecaz')) return true;
if (g.leaderSkills && nativeEcazLeaderSkillsProfile(g)) return true;
if (g.ecazOccupyPreview === true && (mixedE3NexusProfile(g) || standaloneE3NexusProfile(g) || pairedE3NexusProfile(g)) &&
@@ -10779,7 +10792,7 @@ function ecazOccupyCompositionSupported(g: Game): boolean {
g.expansions.includes('ecaz') &&
g.expansions.every(id => ['ecaz', 'ix', 'choam'].includes(id)) &&
new Set(g.expansions).size === g.expansions.length &&
(g.advanced || (g.expansions.length === 1 && g.expansions[0] === 'ecaz' &&
(g.advanced || basicTleilaxu || (g.expansions.length === 1 && g.expansions[0] === 'ecaz' &&
g.players.every(p => p.faction === 'ecaz' ||
p.faction === 'moritani' || CLASSIC_FACTIONS[p.faction]))) &&
g.players.length >= 2 && g.players.length <= 6 &&
@@ -10797,7 +10810,7 @@ function ecazOccupyCompositionSupported(g: Game): boolean {
/** Fresh native Occupy profile; Basic odd counts use the main printed rule provisionally. */
export function initializeEcazOccupyGameForAudit(state: Game): Game {
requireRule(ecazOccupyCompositionSupported(state),
'Occupy requires supported native factions and their selected decks; Basic uses Ecaz/classic/optional Moritani and may include the independent cards.');
'Occupy requires supported native factions and their selected decks; Basic additionally admits native Tleilaxu with the bare Ecaz and Ix decks.');
requireFreshBaseRuntime(state);
requireFreshFactionInventory(state);
const g = initializeSetupGameForAudit(state, false, false, false, false, false, false, true, state.ecazTreachery === true);
@@ -18451,6 +18464,59 @@ function ecazAllianceIntegrity(g: Game) {
getPlayer(g, entry.entrant).ally === entry.owner,
'The Duke loan lost its accepted Ecaz alliance.');
}
/** The Ambassador return has happened; the existing free-income ledger decides
* whether its independent bank reward still needs a response. */
function finishAmbassadorRevival(g: Game, beneficiary: Player, returned: AmbassadorRevivalReturn) {
const entry = g.pendingAmbassador!;
collectRevivalIncome(g, beneficiary, 0, true);
if (!g.response) { finishAmbassador(g); return; }
const income = g.response;
requireRule(income.kind === 'revivalIncome' && income.amount === 1,
'The free Ambassador revival lost its bank income.');
entry.revivalReceipt = createAmbassadorRevivalReceipt({
event: entry.event, owner: entry.owner, entrant: entry.entrant, token: entry.token,
territory: entry.territory, sector: entry.sector, turn: entry.turn, phase: entry.phase,
beneficiary: beneficiary.id, incomeOwner: income.owner,
}, returned);
entry.stage = 'income';
income.source = 'ambassadorRevival';
income.intent = entry.event;
log(g, 'Provisional Ambassador revival income: Tleilaxu may collect one bank spice for this beneficiary this turn. The return is already complete; canceling income does not undo it.',
{ faction: 'tleilaxu', name: 'Ambassador revival income' });
}
/** Bind the income to the original committed return, not its pieces' later
* location after an otherwise legal card interruption. */
function validateAmbassadorRevivalIncome(g: Game, response: ResponseWindow) {
const entry = g.pendingAmbassador, receipt = entry?.revivalReceipt;
const token = g.ecazAmbassadors?.tokens.find(t => t.id === entry?.token);
requireRule(g.status === 'playing' && entry && receipt && entry.stage === 'income' &&
entry.effect === 'tleilaxu' && entry.turn === g.turn && entry.phase === g.phase &&
ambassadorPhaseAllowed(g) && response.kind === 'revivalIncome' &&
response.source === 'ambassadorRevival' && response.intent === entry.event && response.amount === 1 &&
response.owner === byFaction(g, 'tleilaxu')?.id && response.recipient === entry.beneficiary &&
byFaction(g, 'ecaz')?.id === entry.owner && g.players.some(p => p.id === entry.entrant) &&
g.players.some(p => p.id === entry.beneficiary) && g.revivalFreeIncome?.[entry.beneficiary!] === g.turn &&
token && ((token.effect === 'tleilaxu' && token.zone === 'used') ||
(token.effect === 'beneGesserit' && token.zone === 'removed' && entry.copyChoices.includes('tleilaxu'))),
'This Ambassador revival income has lost its committed return.');
try {
validateAmbassadorRevivalReceipt({ event: entry.event, owner: entry.owner, entrant: entry.entrant,
token: entry.token, territory: entry.territory, sector: entry.sector, turn: entry.turn, phase: entry.phase,
beneficiary: entry.beneficiary!, incomeOwner: response.owner }, receipt);
} catch (error) {
if (error instanceof AmbassadorRevivalIncomeError) throw new RuleError(error.message);
throw error;
}
}
function ambassadorRevivalIncomeIntegrity(g: Game) {
const responses = [...new Set([...savedNoFieldResponses(g), ...savedKaramaContexts(g).flatMap(({ pending }) =>
pending.use.kind === 'cancel' ? [pending.use.response] : [])])]
.filter(response => response.source === 'ambassadorRevival');
const entry = g.pendingAmbassador;
if (!entry?.revivalReceipt && !(entry?.effect === 'tleilaxu' && entry.stage === 'income') && !responses.length) return;
requireRule(responses.length === 1, 'The committed Ambassador revival needs its original income response.');
validateAmbassadorRevivalIncome(g, responses[0]);
}
function decideAmbassador(g: Game, p: Player, action: Action) {
const entry = g.pendingAmbassador;
requireRule(
@@ -18541,12 +18607,13 @@ function decideAmbassador(g: Game, p: Player, action: Action) {
'Choose a leader or forces, not both.');
const selected = tleilaxuAmbassadorLeaderSelection(g, p, action.leader);
requireRule(selected, 'Choose an eligible own leader, Ecaz Duke or Kwisatz Haderach from the Tanks.');
let name: string;
let name: string, returned: AmbassadorRevivalReturn;
if (selected === 'kwisatz') {
const kwisatz = p.kwisatz!;
kwisatz.dead = false;
delete kwisatz.usedAt;
kwisatz.revivalCycle = Math.max(p.revivalCycle, kwisatz.revivalCycle ?? 1) + 1;
returned = { kind: 'kwisatz', cycle: kwisatz.revivalCycle };
name = 'Kwisatz Haderach';
} else if (selected === DUKE_VIDAL_ID) {
const duke = g.dukeVidal!.leader;
@@ -18555,16 +18622,18 @@ function decideAmbassador(g: Game, p: Player, action: Action) {
delete duke.usedAt;
delete duke.concealed;
name = duke.name;
returned = { kind: 'leader', leader: duke.id, deaths: duke.deaths };
g.dukeVidal = consumeDuke(g.dukeVidal!);
} else {
recordEcazLeaderReturn(g, selected);
selected.dead = false;
delete selected.usedAt;
name = selected.name;
returned = { kind: 'leader', leader: selected.id, deaths: selected.deaths };
}
log(g, `${p.name} revived ${name} for free through the Tleilaxu Ambassador.${selected === DUKE_VIDAL_ID ? ' The living shared disc is set aside, unclaimed.' : ''} The ordinary leader allowance is unchanged.`,
{ faction: 'tleilaxu', name: 'Tleilaxu Ambassador' });
finishAmbassador(g);
finishAmbassadorRevival(g, p, returned);
return;
}
const forces = integer(action.forces, 1, 4, 'Forces to return');
@@ -18604,10 +18673,10 @@ function decideAmbassador(g: Game, p: Player, action: Action) {
}
log(
g,
`${p.name} returned ${quote.amount} physical ${quote.amount === 1 ? 'force' : 'forces'} (${quote.elite} special) from the Tanks to its reserves for free through the Tleilaxu Ambassador. Ordinary revival allowances, prices and income are unchanged.`,
`${p.name} returned ${quote.amount} physical ${quote.amount === 1 ? 'force' : 'forces'} (${quote.elite} special) from the Tanks to its reserves for free through the Tleilaxu Ambassador. Ordinary revival allowances and prices are unchanged.`,
{ faction: 'tleilaxu', name: 'Tleilaxu Ambassador' },
);
finishAmbassador(g);
finishAmbassadorRevival(g, p, { kind: 'forces', amount: quote.amount, elite: quote.elite });
return;
}
if (entry.stage === 'offer') {
@@ -26142,6 +26211,8 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
: null;
if (response.source === 'ambassador')
validateAmbassadorPurchaseResponse(g, response);
if (response.source === 'ambassadorRevival')
validateAmbassadorRevivalIncome(g, response);
const placementCancellation = canceled
? currentPlacementCancellationQuote(g, response)
: null;
@@ -26831,6 +26902,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
awardBankerIncome(g, response.spiceBankerIncomePayment, canceled);
if (g.pendingChoamMarketGhola && g.pendingChoamMarketGhola.event === response.intent)
g.pendingChoamMarketGhola.stage = 'complete';
if (response.source === 'ambassadorRevival') finishAmbassador(g);
} else if (response.kind === 'faceDancerReplacement') {
const owner = getPlayer(g, response.owner);
const index = owner.faceDancers!.findIndex(
@@ -30511,6 +30583,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
shipmentPromiseIntegrity(state);
saphoMovementIntegrity(state);
ambassadorRelocationIntegrity(state);
ambassadorRevivalIncomeIntegrity(state);
ecazCollectionIntegrity(state);
ecazAllianceIntegrity(state);
// Seat control is independent of gameplay locks and must preserve their exact continuation.
@@ -30917,6 +30990,7 @@ export function normalizeAutomaticGame(state: Game): Game {
shipmentPromiseIntegrity(state);
saphoMovementIntegrity(state);
ambassadorRelocationIntegrity(state);
ambassadorRevivalIncomeIntegrity(state);
ecazCollectionIntegrity(state);
ecazAllianceIntegrity(state);
const g = structuredClone(state);
@@ -35129,6 +35203,7 @@ export function viewGame(state: Game, id: string) {
shipmentPromiseIntegrity(state);
saphoMovementIntegrity(state);
ambassadorRelocationIntegrity(state);
ambassadorRevivalIncomeIntegrity(state);
ecazCollectionIntegrity(state);
ecazAllianceIntegrity(state);
battleSpecialWeaponIntegrity(state);
+2 -1
View File
@@ -224,7 +224,8 @@ const cancellationSources = {
revivalLimit: (g) => g.pendingRevival ?? null,
revivalDiscount: (g) => g.pendingRevival ?? null,
earlyRevival: (g) => g.pendingRevival ?? null,
revivalIncome: () => null,
revivalIncome: (g, response) => response.source === 'ambassadorRevival'
? g.pendingAmbassador?.revivalReceipt ?? null : null,
foreignGhola: (g) => g.pendingRevival ?? null,
ixSubstitution: (g) => ({
...aftermathSource(g),
+14 -10
View File
@@ -1453,7 +1453,7 @@ export const RULE_TOPICS: RuleTopic[] = [
summary: 'Ecaz chooses who leads its combined army. Basic provisionally follows the main printed paragraph for odd as well as even counts: ceiling contribution/loss and floor survivors. Advanced retains its user-authorized revision. Usable controls and legal participation are connected; interaction refinement remains.',
steps: [
'When reciprocal Ecaz allies coexist with fighting forces, Ecaz chooses Ecaz or its ally to lead. Every participating whole-territory army must be clear of storm and connected; no component-only battle or casualties are created. The selected lead uses its own leaders, Treachery cards and spice; the original battle-order chooser is not replaced. Advisors are not fighters but retain their native voluntary fighter-conversion opportunity.',
'Basic combined battles use the native Ecaz deck and existing roster prerequisites. Ecaz contributes the ceiling of half its forces at full strength and loses that many on an ordinary win; survivors round down. This follows the main printed paragraph provisionally because its odd-count FAQ example conflicts. A visible control warning distinguishes the prototype from a settled correction. Basic has no Emperor/Fremen elite inventory and its supplied support normalizes to zero.',
'Basic combined battles use the native Ecaz deck and existing roster prerequisites. Bare Ecaz+Ix with native Tleilaxu now supports ordinary Tleilaxu coalition and Face Dance behavior, with classic seats and optional Moritani; it adds no other Basic native faction or optional-module combination. Ecaz contributes the ceiling of half its forces at full strength and loses that many on an ordinary win; survivors round down. This follows the main printed paragraph provisionally because its odd-count FAQ example conflicts. A visible control warning distinguishes the prototype from a settled correction. Basic has no Emperor/Fremen elite inventory and its supplied support normalizes to zero.',
'With Advanced Occupy active, the total dial includes the ceiling of half the Ecaz fighters as mandatory full-strength contribution without spice cost, plus the chosen allied army strength. The variable army retains its native Fremen/Fedaykin or Emperor/Sardaukar properties even when Ecaz forms the plan.',
'An ordinary victory leaves the floor of half the Ecaz fighters alive and separately applies the variable army’s dial losses. A sole successful Traitor-call winner preserves both armies; an ordinary defeat, mutual Traitor result or resolved explosion removes the actual combined side. Ecaz and its ally share the printed same-faction Traitor/Face Dancer relation.',
'Eligible Karama can prevent Occupy after the lead choice and before powers/plans. The chosen lead remains lead and dials its own army; the other ally contributes zero. The plan editor then uses the new force pool and no fixed Ecaz strength.',
@@ -1470,7 +1470,7 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Player controls', status: 'Partial', detail: 'Original total-wheel, lead and Karama controls now support native mixed Richese as ally or opponent. Selected Ecaz sees the private mixed variable notice, not its own maximum; prevention returns to selected-only forces. Four new390px journeys include capped mixed Ecaz lead, funded H/R Richese lead, opposing zero with ordinary counters and actual Karama redial. Earlier native/basic controls remain historical evidence.', evidence: ['components/ecaz-occupy.tsx', 'components/face-dance-decision.tsx', 'components/game-table.tsx', 'game/ecaz-occupy-options.ts'] },
{ area: 'AI', status: 'Partial', detail: 'All four native policies actually seal legal total plans for either lead, active or canceled, using native leaders/cards and physical support. Strategy remains deferred.', evidence: ['game/ecaz-occupy-options.ts', 'game/bots.ts', 'tests/ecaz-occupy-options.test.ts'] },
{ area: 'Documentation', status: 'Implemented', detail: 'Authorized Advanced arithmetic and provisional main-paragraph Basic arithmetic are distinguished from the conflicting FAQ, specific Duke ruling and deferred interaction refinement.', evidence: ['docs/ECAZ_OCCUPY_RULES.md', 'docs/RULE_DECISIONS.md', 'game/reference.ts'] },
{ area: 'Verification', status: 'Partial', detail: 'Current mixed regressions cover both physical roles/leads, zero/caps, prevention, original support/losses, real Traitors and H/R funding/disposal. Four actual mobile journeys and16 native programs/80 policy actions reach original Collection. Earlier363/26-program and Basic full-game evidence remains historical, not a fresh broad/recovery/deployed certification. Complete modes, overlays and final assurance remain open.', evidence: ['tests/ecaz-occupy-mixed-no-field.test.ts', 'tests/fixture-ecaz-occupy-mixed-no-field.ts', 'tests/ecaz-occupy-mixed-no-field-opponent.test.ts', 'tests/ecaz-occupy-battle.test.ts', 'tests/basic-ecaz-occupy-runtime.test.ts', 'tests/ecaz-occupy-richese.test.ts', 'tests/ecaz-occupy-stone.test.ts', 'tests/ecaz-occupy-cards-runtime.test.ts', 'tools/faction-games.ts', 'docs/ECAZ_OCCUPY_RULES.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Current mixed regressions cover both physical roles/leads, zero/caps, prevention, original support/losses, real Traitors and H/R funding/disposal. Four actual mobile journeys and16 native programs/80 policy actions reach original Collection. Earlier363/26-program and Basic full-game evidence remains historical, not a fresh broad/recovery/deployed certification. Complete modes, overlays and final assurance remain open.', evidence: ['tests/ecaz-occupy-mixed-no-field.test.ts', 'tests/fixture-ecaz-occupy-mixed-no-field.ts', 'tests/ecaz-occupy-mixed-no-field-opponent.test.ts', 'tests/ecaz-occupy-battle.test.ts', 'tests/basic-ecaz-occupy-runtime.test.ts', 'tests/basic-ecaz-tleilaxu-occupy.test.ts', 'tests/ecaz-occupy-richese.test.ts', 'tests/ecaz-occupy-stone.test.ts', 'tests/ecaz-occupy-cards-runtime.test.ts', 'tools/faction-games.ts', 'docs/ECAZ_OCCUPY_RULES.md'] },
],
related: ['faction-ecaz', 'advanced-ecaz', 'ecaz-ambassadors', 'battle', 'victory', 'implementation-checklist'],
},
@@ -1522,30 +1522,30 @@ export const RULE_TOPICS: RuleTopic[] = [
'The Guild Ambassador grants an immediate free shipment of zero through four physical reserve forces for Ecaz or its ally, including typed elites. Choose a clear territory and sector; this does not spend ordinary shipment or movement, ally aid or a retained rate card. Fremen may use destinations outside their ordinary reinforcement radius, but their southern reserves stay on-planet and this effect never permits storm entry. Only Ixians may ship directly into their placed mobile stronghold.',
'During Shipment and Movement, Guild can use its special Karama to stop another eligible off-planet Ambassador shipment before forces arrive; it does not stop this grant during a phase-one worm sequence. A successful off-planet shipment may invite Bene Gesserit accompaniment and accrues applicable phase-five Heighliner income once. BG can accompany Ixians into the mobile stronghold. Projected accompaniment choices show blocked sectors and keep Polar Sink and decline available when legal. Intrusion, separate Terror and accompanying fighter-triggered Ambassadors finish before the original entrant or worm rider resumes.',
'No-Field substitution into the free four-force grant remains unavailable. In bounded classic plus Ecaz/Moritani games, a shared Ambassador/Terror entry gives both owners saved optional prompts in current storm order without repeating the committed entry; this is a provisional table order, not a printed publisher priority. A Terror entry now waits for the arrival’s own interaction — Guild income, a Bene Gesserit fighter Intrusion or advisor choice, or Fremen storm protection — and then opens without replaying payment or force transfer. Ambassador-created child arrivals retain their own contracts. The native-module extension now composes ordinary paid arrival order with Atomics, Homeworlds and Nexus; other combinations and complete rules remain unfinished.',
'The Tleilaxu Ambassador offers one eligible leader or up to four physical forces free. Ordinary native leaders retain their first-death limit; Atreides may choose its separate first-killed Kwisatz Haderach without adding a roster disc or resetting losses. An Ecaz beneficiary may instead return the original dead shared Duke through his independent permission, preserving death history and any native cohort opening. He becomes alive and set aside, unclaimed; a separate legal acquisition is needed before battle use. Ecaz’s ally cannot inherit that revival permission. Native and BG copied grants share private eligibility. Force/leader choices remain exclusive; ordinary allowance/income independence stays provisional and generic repeat/custody combinations remain separate.',
'All eleven effect guides are readable. Emperor, Atreides, Harkonnen, CHOAM, Ixian, Richese, Fremen and Guild entry effects are integrated, together with Bene Gesserit copies of those eight effects and direct Duke acquisition or consensual alliance through the reusable Ecaz token. Advanced combined Occupy has a separate bounded Development Verified guide, and Basic even-force Occupy now composes the same shared controls. Remaining effects, exceptional Duke custody and complete Ecaz rules remain unfinished. Mobile stronghold placement is gated. Full Ecaz starts remain disabled.',
'The Tleilaxu Ambassador offers one eligible leader or up to four physical forces free. Ordinary native leaders retain their first-death limit; Atreides may choose its separate first-killed Kwisatz Haderach without adding a roster disc or resetting losses. An Ecaz beneficiary may instead return the original dead shared Duke through his independent permission, preserving death history and any native cohort opening. He becomes alive and set aside, unclaimed; a separate legal acquisition is needed before battle use. Ecaz’s ally cannot inherit that revival permission. Native and BG copied grants share private eligibility. Force/leader choices remain exclusive. Under the provisional income interpretation, a positive return earns seated Tleilaxu one bank spice per beneficiary per turn, sharing ordinary free revival; actual Ghola stays separate. Canceling income keeps the return and spent entitlement. Ordinary allowance independence and generic repeat/custody combinations remain provisional.',
'All eleven effect guides are readable. Emperor, Atreides, Harkonnen, CHOAM, Ixian, Richese, Fremen and Guild entry effects are integrated, together with Bene Gesserit copies of those eight effects and direct Duke acquisition or consensual alliance through the reusable Ecaz token. Advanced combined Occupy has a separate bounded Development Verified guide, and Basic even-force Occupy now composes the same shared controls. Remaining effects, exceptional Duke custody and complete Ecaz rules remain unfinished. Mobile stronghold placement is gated. Normal public Ecaz starts remain development previews; complete rules and unsupported combinations stay gated.',
],
checklist: [
{
area: 'Implementation',
status: 'Partial',
detail:
'Original effects, copies, Duke acquisition, consent and token returns remain integrated. Ecaz’s Tleilaxu grant now returns the canonical shared dead Duke alive and set aside, preserving his history and satisfied cohort opening without a paid request, ordinary quota or extra disc. Native shipment/Guild income and BG child/original arrival settlement remain; wider combinations stay partial.',
evidence: ['game/engine.ts', 'game/tleilaxu-ambassador-leaders.ts', 'game/ecaz-duke-revival.ts', 'game/bot-arrival.ts'],
'Original effects, copies, Duke acquisition, consent and token returns remain integrated. Ecaz’s Tleilaxu grant now returns the canonical shared dead Duke alive and set aside, preserving his history and satisfied cohort opening without a paid request, ordinary quota or extra disc. Positive Tleilaxu Ambassador returns now use the shared free-income ledger and a bound committed-return response; allow/cancel finishes the original arrival once. Bare Basic Ecaz+Ix with native Tleilaxu admits existing coalition/Face Dance mechanics; other combinations stay partial.',
evidence: ['game/engine.ts', 'game/tleilaxu-ambassador-leaders.ts', 'game/tleilaxu-ambassador-income.ts', 'game/karama-context.ts', 'game/ecaz-duke-revival.ts', 'game/bot-arrival.ts'],
},
{
area: 'Player controls',
status: 'Partial',
detail:
'Existing placement, effect, consent and movement controls remain. An entitled Duke revival choice explains the alive/unclaimed shared destination before selection. Earlier four actual390px native transport/income/BG journeys remain separate evidence; original entrant movement and once-only payment are retained.',
'Existing placement, effect, consent and movement controls remain. An entitled Duke choice explains the alive/unclaimed destination; free-return and income controls explain the provisional shared reward and cancellation retaining the completed return. Earlier four actual390px native transport/income/BG journeys remain separate evidence; original entrant movement and once-only payment are retained.',
evidence: ['components/ecaz-entry.tsx', 'components/game-table.tsx'],
},
{
area: 'AI',
status: 'Partial',
detail:
'All four existing profiles legally select the sole free Duke alternative in both original Basic native/BG-copy positions and finish the entrant turn: eight policy continuations. Earlier legal placement, consent, native income/acquisition and BG arrival paths remain separate evidence. No strategy refinement or calibration.',
evidence: ['game/bots.ts', 'tests/duke-ambassador-engine.test.ts'],
'All four existing profiles legally select the original free Duke return and an eligible revival-income response, then finish the entrant turn: eight native/BG-copy policy continuations with Tleilaxu seated. Earlier placement, consent, income/acquisition and BG arrival evidence remains separate. No strategy refinement or calibration.',
evidence: ['game/bots.ts', 'tests/ambassador-revival-income-engine.test.ts', 'tests/duke-ambassador-engine.test.ts', 'docs/TLEILAXU_AMBASSADOR_RULES.md'],
},
{
area: 'Documentation',
@@ -1558,8 +1558,12 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Verification',
status: 'Partial',
detail:
'Current Duke-grant5-file42/42, types/lint/build, two original Basic gameplay/rendering programmes and eight legal policies pass. Actual acquisition/death/paid Guild entry precede the free original-disc return and movement completion. Controlled Advanced/repeat/cohort/ownership/reacquisition probes and earlier native arrival/browser evidence remain separate; full mode, combination, browser/deployed and final assurance stay open.',
'Current income8-file81/81, types/lint/build, four original Basic native/copy allow/cancel gameplay/rendering programmes and eight legal policies pass. Original47/22 components, actual acquisition/death/paid Guild entry and dealt Karama precede the return and bound bank income. Six labelled Basic Tleilaxu coalition consumers retain actual setup/alliance/funded battles. Earlier Duke-only42 and arrival/browser evidence remain separate; full modes, wider combinations and deployed/final assurance stay open.',
evidence: [
'tests/fixture-ambassador-revival-income.ts',
'tests/ambassador-revival-income-engine.test.ts',
'tests/tleilaxu-ambassador-income.test.ts',
'tests/basic-ecaz-tleilaxu-occupy.test.ts',
'tests/fixture-duke-ambassador.ts',
'tests/duke-ambassador-engine.test.ts',
'tests/tleilaxu-ambassador-leaders.test.ts',
+101
View File
@@ -0,0 +1,101 @@
export type AmbassadorRevivalSource = {
event: string;
owner: string;
entrant: string;
token: string;
territory: string;
sector: number;
turn: number;
phase: number;
beneficiary: string;
incomeOwner: string;
};
export type AmbassadorRevivalReturn =
| { kind: 'forces'; amount: number; elite: number }
| { kind: 'leader'; leader: string; deaths: number }
| { kind: 'kwisatz'; cycle: number };
export type AmbassadorRevivalReceipt = {
version: 1;
source: AmbassadorRevivalSource;
returned: AmbassadorRevivalReturn;
amount: 1;
signature: string;
};
export class AmbassadorRevivalIncomeError extends Error {
constructor(message: string) {
super(message);
this.name = 'AmbassadorRevivalIncomeError';
}
}
function requireIncome(value: unknown, message: string): asserts value {
if (!value) throw new AmbassadorRevivalIncomeError(message);
}
const text = (value: unknown): value is string =>
typeof value === 'string' && value.trim().length > 0;
const count = (value: unknown): value is number =>
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
const exact = (value: unknown, keys: string): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value) &&
[Object.prototype, null].includes(Object.getPrototypeOf(value)) &&
Object.keys(value).sort().join(',') === keys;
function validSource(source: unknown): source is AmbassadorRevivalSource {
return exact(source, 'beneficiary,entrant,event,incomeOwner,owner,phase,sector,territory,token,turn') &&
text(source.event) && text(source.owner) && text(source.entrant) && text(source.token) &&
text(source.territory) && text(source.beneficiary) && text(source.incomeOwner) &&
count(source.sector) && source.sector >= 1 && source.sector <= 18 &&
count(source.turn) && source.turn >= 1 && count(source.phase);
}
function validReturn(returned: unknown): returned is AmbassadorRevivalReturn {
if (exact(returned, 'amount,elite,kind') && returned.kind === 'forces')
return count(returned.amount) && returned.amount >= 1 && returned.amount <= 4 &&
count(returned.elite) && returned.elite <= returned.amount;
if (exact(returned, 'deaths,kind,leader') && returned.kind === 'leader')
return text(returned.leader) && count(returned.deaths) && returned.deaths >= 1;
return exact(returned, 'cycle,kind') && returned.kind === 'kwisatz' &&
count(returned.cycle) && returned.cycle >= 1;
}
const sourceValues = (source: AmbassadorRevivalSource) => [
source.event, source.owner, source.entrant, source.token, source.territory,
source.sector, source.turn, source.phase, source.beneficiary, source.incomeOwner,
];
function signature(receipt: AmbassadorRevivalReceipt): string {
const returned = receipt.returned;
return JSON.stringify([
'ambassadorRevivalReceipt', receipt.version, sourceValues(receipt.source),
returned.kind === 'forces' ? ['forces', returned.amount, returned.elite] :
returned.kind === 'leader' ? ['leader', returned.leader, returned.deaths] : ['kwisatz', returned.cycle],
receipt.amount,
]);
}
/** Bind an already-committed positive return, not permission to revive or pay.
* The native parent validates actual pieces/custody and owns the existing free
* income ledger. Canceling the queued income cannot undo this return. */
export function createAmbassadorRevivalReceipt(
source: AmbassadorRevivalSource,
returned: AmbassadorRevivalReturn,
): AmbassadorRevivalReceipt {
requireIncome(validSource(source), 'Ambassador revival income needs its exact original source.');
requireIncome(validReturn(returned), 'Ambassador revival income needs one positive physical return.');
const receipt: AmbassadorRevivalReceipt = {
version: 1, source: { ...source }, returned: { ...returned }, amount: 1, signature: '',
};
receipt.signature = signature(receipt);
return receipt;
}
export function validateAmbassadorRevivalReceipt(
source: AmbassadorRevivalSource,
receipt: AmbassadorRevivalReceipt,
): void {
requireIncome(validSource(source), 'Ambassador revival income needs its exact original source.');
requireIncome(
exact(receipt, 'amount,returned,signature,source,version') && receipt.version === 1 && receipt.amount === 1,
'The Ambassador revival income receipt has changed its fixed reward or shape.',
);
requireIncome(
validSource(receipt.source) && JSON.stringify(sourceValues(source)) === JSON.stringify(sourceValues(receipt.source)),
'The Ambassador revival income receipt no longer matches its original source.',
);
requireIncome(validReturn(receipt.returned), 'Ambassador revival income needs one positive physical return.');
requireIncome(receipt.signature === signature(receipt), 'The Ambassador revival income receipt has changed its committed return.');
}