Files
Dune/game/tleilaxu-ambassador-income.ts
T
Quintine 6d30e7c5e1 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.
2026-10-24 13:07:08 +11:00

102 lines
4.7 KiB
TypeScript

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.');
}