140 lines
4.3 KiB
TypeScript
140 lines
4.3 KiB
TypeScript
import { FACTIONS, type FactionId } from './catalog';
|
|
import { emperorNexusEvent } from './nexus-emperor-secret-ally';
|
|
|
|
export type EmperorNexusPurchase = {
|
|
kind: 'purchase';
|
|
event: string;
|
|
owner: string;
|
|
turn: number;
|
|
phase: 3;
|
|
sequence: number;
|
|
faction: FactionId;
|
|
advanced: boolean;
|
|
price: number;
|
|
auctionIndex: number;
|
|
card: string;
|
|
beforeSpice: number;
|
|
afterSpice: number;
|
|
signature: string;
|
|
};
|
|
|
|
/** Bind an offer to its public auction position and final bid, never its hidden card face. */
|
|
export function emperorNexusPurchaseEvent(
|
|
turn: number, owner: string, sequence: number, auctionIndex: number, price: number,
|
|
): string {
|
|
return `${emperorNexusEvent(turn, 3, owner, sequence)}:${auctionIndex}:${price}`;
|
|
}
|
|
|
|
export class EmperorNexusPurchaseError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = 'EmperorNexusPurchaseError';
|
|
}
|
|
}
|
|
|
|
/** Only a personally affordable, bank-paid winning bid can be reimbursed. */
|
|
export function quoteEmperorNexusPurchase(
|
|
price: number,
|
|
ownSpice: number,
|
|
allyPayment: number,
|
|
): void {
|
|
if (
|
|
!Number.isSafeInteger(price) ||
|
|
price < 1 ||
|
|
!Number.isSafeInteger(ownSpice) ||
|
|
ownSpice < price ||
|
|
allyPayment !== 0
|
|
)
|
|
throw new EmperorNexusPurchaseError(
|
|
'Emperor Nexus purchase requires a positive whole bid paid entirely from the winner\'s own spice.',
|
|
);
|
|
}
|
|
|
|
/** Consistency signature, not cryptographic authentication; field order is fixed. */
|
|
export function signEmperorNexusPurchase(record: EmperorNexusPurchase): string {
|
|
return JSON.stringify([
|
|
'emperorNexusPurchase',
|
|
record.kind,
|
|
record.event,
|
|
record.owner,
|
|
record.turn,
|
|
record.phase,
|
|
record.sequence,
|
|
record.faction,
|
|
record.advanced,
|
|
record.price,
|
|
record.auctionIndex,
|
|
record.card,
|
|
record.beforeSpice,
|
|
record.afterSpice,
|
|
]);
|
|
}
|
|
|
|
const whole = (value: unknown): value is number =>
|
|
Number.isSafeInteger(value) && (value as number) >= 0;
|
|
const text = (value: unknown): value is string =>
|
|
typeof value === 'string' && value.trim().length > 0;
|
|
const plain = (value: unknown): value is Record<string, unknown> =>
|
|
value !== null &&
|
|
typeof value === 'object' &&
|
|
!Array.isArray(value) &&
|
|
[Object.prototype, null].includes(Object.getPrototypeOf(value));
|
|
|
|
/** Historical purchase evidence survives subsequent auctions and card transfers. */
|
|
export function validateEmperorNexusPurchase(
|
|
context: {
|
|
turn: number;
|
|
advanced: boolean;
|
|
players: readonly { id: string; faction: FactionId }[];
|
|
},
|
|
record: EmperorNexusPurchase,
|
|
): void {
|
|
const validContext =
|
|
context &&
|
|
whole(context.turn) &&
|
|
context.turn > 0 &&
|
|
typeof context.advanced === 'boolean' &&
|
|
Array.isArray(context.players) &&
|
|
context.players.length > 0 &&
|
|
context.players.every(
|
|
(player) =>
|
|
player &&
|
|
text(player.id) &&
|
|
FACTIONS.some((faction) => faction.id === player.faction),
|
|
) &&
|
|
new Set(context.players.map((player) => player.id)).size ===
|
|
context.players.length &&
|
|
new Set(context.players.map((player) => player.faction)).size ===
|
|
context.players.length;
|
|
if (
|
|
!validContext ||
|
|
!plain(record) ||
|
|
Object.keys(record).sort().join(',') !==
|
|
'advanced,afterSpice,auctionIndex,beforeSpice,card,event,faction,kind,owner,phase,price,sequence,signature,turn' ||
|
|
record.kind !== 'purchase' ||
|
|
record.phase !== 3 ||
|
|
!whole(record.turn) ||
|
|
record.turn < 1 ||
|
|
record.turn > context.turn ||
|
|
!whole(record.sequence) ||
|
|
!text(record.owner) ||
|
|
!text(record.card) ||
|
|
context.players.find((player) => player.id === record.owner)?.faction !==
|
|
record.faction ||
|
|
record.advanced !== context.advanced ||
|
|
record.event !==
|
|
emperorNexusPurchaseEvent(record.turn, record.owner, record.sequence, record.auctionIndex, record.price) ||
|
|
!whole(record.auctionIndex) ||
|
|
!whole(record.beforeSpice) ||
|
|
!whole(record.afterSpice) ||
|
|
!whole(record.price) ||
|
|
record.price < 1 ||
|
|
record.beforeSpice < record.price ||
|
|
record.afterSpice !== record.beforeSpice ||
|
|
record.signature !== signEmperorNexusPurchase(record)
|
|
)
|
|
throw new EmperorNexusPurchaseError(
|
|
'Emperor Nexus purchase has lost its original bidder, paid price or full reimbursement.',
|
|
);
|
|
}
|