Bind Truthtrance Karama promises to shipment beneficiaries
This commit is contained in:
1 parent
fd0b2f0fa5
commit
fd38c51ffd
19 files changed
+1148
-41
No files matched your search
@@ -1,11 +1,15 @@
|
||||
import { harvesterAvailable } from './cards';
|
||||
|
||||
export const WINDOW_CARD_EFFECTS = ['weather', 'atomics', 'harvester', 'karamaAuction'] as const;
|
||||
export const WINDOW_CARD_EFFECTS = ['weather', 'atomics', 'harvester', 'karamaAuction', 'karamaShipment'] as const;
|
||||
export type WindowCardEffect = typeof WINDOW_CARD_EFFECTS[number];
|
||||
export function windowCardNativeEffect(effect: WindowCardEffect): 'weather' | 'atomics' | 'harvester' | 'karama' {
|
||||
return effect === 'karamaAuction' ? 'karama' : effect;
|
||||
return effect === 'karamaAuction' || effect === 'karamaShipment' ? 'karama' : effect;
|
||||
}
|
||||
export type WindowCardOpportunity = { effect: WindowCardEffect; source: string; turn: number };
|
||||
export type KaramaShipmentOpportunity = { effect: 'karamaShipment'; source: string; turn: number; recipient: string };
|
||||
export type KaramaShipmentSource = { turn: number; recipient: string; kind: 'original' } |
|
||||
{ turn: number; recipient: string; kind: 'cunning'; event: string };
|
||||
export type WindowCardOpportunity = { effect: Exclude<WindowCardEffect, 'karamaShipment'>; source: string; turn: number } |
|
||||
KaramaShipmentOpportunity;
|
||||
export type WindowCardGame = {
|
||||
status: string;
|
||||
phase: number;
|
||||
@@ -31,6 +35,13 @@ export type WindowCardGame = {
|
||||
richeseAuction?: unknown;
|
||||
currentAuctionSale?: unknown;
|
||||
ixAuction?: unknown;
|
||||
active?: string | null;
|
||||
movementRemaining?: readonly string[] | null;
|
||||
players?: readonly { id: string; shipped?: boolean }[];
|
||||
karamaShipping?: { player: string } | null;
|
||||
nexusGuildCunningHistory?: readonly { receipt: { turn: number; owner: string; event: string }; stage: string }[];
|
||||
/** Public projection contains no physical cards or private funding. */
|
||||
karamaShipmentWindows?: readonly KaramaShipmentOpportunity[];
|
||||
};
|
||||
|
||||
const object = (value: unknown): value is Record<string, unknown> =>
|
||||
@@ -40,9 +51,73 @@ const identifier = (value: unknown): value is string =>
|
||||
const whole = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
||||
|
||||
/** Source decoding remains available after the actual grant closes its window. */
|
||||
export function parseKaramaShipmentSource(source: unknown): KaramaShipmentSource | null {
|
||||
if (!identifier(source)) return null;
|
||||
let tuple: unknown;
|
||||
try { tuple = JSON.parse(source); } catch { return null; }
|
||||
if (!Array.isArray(tuple) || tuple[0] !== 'karamaShipment' || !whole(tuple[1]) || tuple[1] < 1 ||
|
||||
!identifier(tuple[2]) || JSON.stringify(tuple) !== source) return null;
|
||||
if (tuple.length === 4 && tuple[3] === 'original') return { turn: tuple[1], recipient: tuple[2], kind: 'original' };
|
||||
return tuple.length === 5 && tuple[3] === 'cunning' && identifier(tuple[4])
|
||||
? { turn: tuple[1], recipient: tuple[2], kind: 'cunning', event: tuple[4] } : null;
|
||||
}
|
||||
|
||||
/** Every still-unused original entitlement remains bound through other actors'
|
||||
* turns. A declared Cunning continuation permanently retires its original one. */
|
||||
export function karamaShipmentOpportunities(game: WindowCardGame): KaramaShipmentOpportunity[] {
|
||||
if (game.status !== 'playing' || game.phase !== 5 || !whole(game.turn) || game.turn < 1 ||
|
||||
!Array.isArray(game.players) || !game.players.length ||
|
||||
!game.players.every(player => object(player) && identifier(player.id)) ||
|
||||
!Array.isArray(game.movementRemaining)) return [];
|
||||
const players = game.players, seated = new Set(players.map(player => player.id));
|
||||
const remaining = game.movementRemaining;
|
||||
if (seated.size !== players.length || !remaining.every(recipient => identifier(recipient) && seated.has(recipient)) ||
|
||||
new Set(remaining).size !== remaining.length ||
|
||||
game.active != null && (!identifier(game.active) || !remaining.includes(game.active))) return [];
|
||||
const grant = game.karamaShipping;
|
||||
if (grant != null && (!object(grant) || !identifier(grant.player) || !seated.has(grant.player))) return [];
|
||||
if ('karamaShipmentWindows' in game) {
|
||||
if (!Array.isArray(game.karamaShipmentWindows)) return [];
|
||||
const sources = new Set<string>(), recipients = new Set<string>(), result: KaramaShipmentOpportunity[] = [];
|
||||
for (const window of game.karamaShipmentWindows) {
|
||||
if (!object(window) || Object.keys(window).sort().join(',') !== 'effect,recipient,source,turn' ||
|
||||
window.effect !== 'karamaShipment' || window.turn !== game.turn || !identifier(window.recipient) ||
|
||||
!remaining.includes(window.recipient) || grant?.player === window.recipient ||
|
||||
!identifier(window.source) || sources.has(window.source) || recipients.has(window.recipient)) return [];
|
||||
const parsed = parseKaramaShipmentSource(window.source);
|
||||
if (!parsed || parsed.turn !== game.turn || parsed.recipient !== window.recipient ||
|
||||
parsed.kind === 'cunning' && window.recipient !== game.active) return [];
|
||||
sources.add(window.source); recipients.add(window.recipient);
|
||||
result.push({ effect: 'karamaShipment', turn: game.turn, source: window.source, recipient: window.recipient });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
if (!players.every(player => typeof player.shipped === 'boolean')) return [];
|
||||
const history = game.nexusGuildCunningHistory;
|
||||
if (history !== undefined && (!Array.isArray(history) || !history.every(record => object(record) && object(record.receipt) &&
|
||||
whole(record.receipt.turn) && record.receipt.turn > 0 && record.receipt.turn <= game.turn &&
|
||||
identifier(record.receipt.owner) && seated.has(record.receipt.owner) && identifier(record.receipt.event) &&
|
||||
typeof record.stage === 'string' && ['pending', 'secondShipment', 'extraMove', 'completed', 'canceled'].includes(record.stage)) ||
|
||||
new Set(history.map(record => record.receipt.event)).size !== history.length)) return [];
|
||||
const current = history?.at(-1);
|
||||
return remaining.flatMap(recipient => {
|
||||
if (grant?.player === recipient) return [];
|
||||
const cunning = history?.some(record => record.receipt.turn === game.turn && record.receipt.owner === recipient);
|
||||
if (cunning) {
|
||||
if (!current || current.receipt.turn !== game.turn || current.receipt.owner !== recipient ||
|
||||
current.stage !== 'secondShipment' || game.active !== recipient) return [];
|
||||
return [{ effect: 'karamaShipment' as const, turn: game.turn, recipient,
|
||||
source: JSON.stringify(['karamaShipment', game.turn, recipient, 'cunning', current.receipt.event]) }];
|
||||
}
|
||||
return players.find(player => player.id === recipient)!.shipped ? [] : [{ effect: 'karamaShipment' as const,
|
||||
turn: game.turn, recipient, source: JSON.stringify(['karamaShipment', game.turn, recipient, 'original']) }];
|
||||
});
|
||||
}
|
||||
|
||||
/** Original global window only. Temporary modal priority, actual card custody,
|
||||
* force conditions and chosen effect parameters remain native engine rules. */
|
||||
export function windowCardOpportunity(game: WindowCardGame, effect: WindowCardEffect): WindowCardOpportunity | null {
|
||||
function globalWindowCardOpportunity(game: WindowCardGame, effect: WindowCardEffect): WindowCardOpportunity | null {
|
||||
if (game.status !== 'playing' || !whole(game.turn) || game.turn < 1) return null;
|
||||
if (effect === 'karamaAuction') {
|
||||
if (game.phase !== 3 || game.richeseAuction != null || game.currentAuctionSale != null || game.ixAuction != null ||
|
||||
@@ -79,3 +154,13 @@ export function windowCardOpportunity(game: WindowCardGame, effect: WindowCardEf
|
||||
if (blow.event !== undefined && !identifier(blow.event)) return null;
|
||||
return { effect, source: blow.event ?? `legacy-spice-blow:${game.turn}:${blow.territory}:${blow.sector}`, turn: game.turn };
|
||||
}
|
||||
|
||||
/** New shipment questions use the current recipient. Accepted rows can instead
|
||||
* retain an exact original source while a different recipient is active. */
|
||||
export function windowCardOpportunity(game: WindowCardGame, effect: WindowCardEffect,
|
||||
source?: string): WindowCardOpportunity | null {
|
||||
if (effect === 'karamaShipment') return karamaShipmentOpportunities(game).find(window =>
|
||||
source === undefined ? window.recipient === game.active : window.source === source) ?? null;
|
||||
const window = globalWindowCardOpportunity(game, effect);
|
||||
return window && (source === undefined || window.source === source) ? window : null;
|
||||
}
|
||||
Reference in new issue
Block a user