Support sector-specific Truthtrance shipments
This commit is contained in:
1 parent
8006e53580
commit
536d334543
27 files changed
+1121
-71
No files matched your search
@@ -0,0 +1,48 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, newPlayer, type Action, type Game } from '../game/engine';
|
||||
import { baseDeck } from '../game/cards';
|
||||
|
||||
/** Controlled native Basic state adapted from truth-travel-guild-fremen:
|
||||
* Fremen and Guild have an already-formed alliance, the Fremen has one
|
||||
* physical fighter in Arrakeen and 19 in reserves, and Harkonnen owns the two
|
||||
* original Truthtrances. This deliberately does not claim genuine setup or
|
||||
* alliance provenance. The deck and force totals are physical and all later
|
||||
* card, question, answer, transport and ground-movement actions are native. */
|
||||
export function createSectorGuildGuardFixture() {
|
||||
const game = createGame('SECTORGUILDGUARDS', newPlayer('a', 'Asker', 'harkonnen'), false);
|
||||
game.players.push(newPlayer('p', 'Fremen', 'fremen'), newPlayer('g', 'Guild', 'guild'));
|
||||
Object.assign(game, { status: 'playing', phase: 5, turn: 2, storm: 18, active: 'p',
|
||||
order: ['p', 'a', 'g'], movementRemaining: ['p', 'a', 'g'], deck: baseDeck(), discard: [] });
|
||||
for (const player of game.players) {
|
||||
player.hand = []; player.forces = {}; player.reserves = 20; player.spice = 10; player.traitorChoices = [];
|
||||
}
|
||||
const fremen = game.players.find(player => player.id === 'p')!, guild = game.players.find(player => player.id === 'g')!;
|
||||
fremen.forces = { 'arrakeen:10': 1 }; fremen.reserves = 19; fremen.ally = 'g'; guild.ally = 'p';
|
||||
const truths = game.deck.filter(card => card.effect === 'truthtrance'); assert.equal(truths.length, 2);
|
||||
game.deck = game.deck.filter(card => card.effect !== 'truthtrance');
|
||||
game.players.find(player => player.id === 'a')!.hand = truths;
|
||||
const inventory = (state: Game) => [...state.deck, ...state.discard, ...state.players.flatMap(player => player.hand)]
|
||||
.map(card => card.id).sort();
|
||||
const original = inventory(game);
|
||||
assert.equal(new Set(original).size, original.length);
|
||||
const claim = { kind: 'action' as const, action: { kind: 'ship' as const, territory: 'imperial_basin', minimum: 1, sector: 9 } };
|
||||
const ship = (territory: string, sector: number): Action => ({ type: 'guildShip', from: 'reserves',
|
||||
territory, sector, amount: 1, elite: 0, allyPayment: 0 });
|
||||
const movement: Action = { type: 'move', from: 'arrakeen:10', territory: 'oh_gap', sector: 9, amount: 1 };
|
||||
return { game, target: 'p', asker: 'a', guild: 'g', truths: truths.map(card => card.id),
|
||||
claim, ship, movement, inventory, original };
|
||||
}
|
||||
export type SectorGuildGuardFixture = ReturnType<typeof createSectorGuildGuardFixture>;
|
||||
export function bindSectorGuildGuard(f: SectorGuildGuardFixture, mixed: unknown = f.claim,
|
||||
answer: 'yes' | 'no' = 'yes'): Game {
|
||||
let game = applyAction(f.game, f.asker, { type: 'card', card: f.truths[0] });
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const player = game.players.find(candidate => !game.truthtrance!.passed.includes(candidate.id));
|
||||
assert.ok(player); game = applyAction(game, player.id, { type: 'truthPass' });
|
||||
}
|
||||
assert.equal(game.truthtrance?.stage, 'ask');
|
||||
game = applyAction(game, f.asker, { type: 'truthAsk', question: {
|
||||
kind: 'turnActions', target: f.target, turn: game.turn, mixed: structuredClone(mixed),
|
||||
} });
|
||||
return applyAction(game, f.target, { type: 'truthAnswer', answer });
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { type Action, type Game } from '../game/engine';
|
||||
import {
|
||||
actTruthTurnAction, createTruthTurnActionFixture, declareTruthTurnAction,
|
||||
truthTurnActionInventory, truthTurnActionPlayer,
|
||||
} from './fixture-truth-turn-action';
|
||||
|
||||
export const actTruthShipmentSector = actTruthTurnAction;
|
||||
export const sectorShipment = (territory: string, sector: number, minimum = 1) => ({
|
||||
kind: 'action' as const, action: { kind: 'ship' as const, territory, minimum, sector },
|
||||
});
|
||||
/** Genuine original Harkonnen/Atreides/Emperor Basic setup, including both
|
||||
* truly dealt original Truthtrances, real auction/Revival and prior movement.
|
||||
* This adapter selects two printed Imperial Basin sectors outside the actual
|
||||
* current storm; it changes no card, resource, piece, clock or saved state. */
|
||||
export function createTruthShipmentSectorFixture() {
|
||||
const base = createTruthTurnActionFixture();
|
||||
const game = structuredClone(base.game), territory = 'imperial_basin';
|
||||
const sectors = [9, 10, 11].filter(sector => sector !== game.storm);
|
||||
assert.ok(sectors.length >= 2); const [sector, alternative] = sectors;
|
||||
assert.equal(game.advanced, false); assert.equal(game.turn, 1); assert.equal(game.phase, 5);
|
||||
assert.equal(game.active, base.target); assert.equal(truthTurnActionPlayer(game, base.target).shipped, false);
|
||||
assert.ok(truthTurnActionPlayer(game, base.target).spice >= 2);
|
||||
assert.deepEqual(truthTurnActionInventory(game), base.originalCards);
|
||||
const mixed = sectorShipment(territory, sector);
|
||||
const shipment = (where: number): Action => ({ type: 'ship', territory, sector: where,
|
||||
amount: 1, elite: 0, allyPayment: 0 });
|
||||
return { base, game, asker: base.asker, target: base.target, observer: base.observer,
|
||||
truths: base.truths, originalCards: base.originalCards, territory, sector, alternative, mixed, shipment };
|
||||
}
|
||||
export type TruthShipmentSectorFixture = ReturnType<typeof createTruthShipmentSectorFixture>;
|
||||
export function askTruthShipmentSector(game: Game, f: TruthShipmentSectorFixture, mixed: unknown = f.mixed): Game {
|
||||
return actTruthShipmentSector(game, f.asker, { type: 'truthAsk', question: {
|
||||
kind: 'turnActions', target: f.target, turn: game.turn, mixed: structuredClone(mixed),
|
||||
} } as Action);
|
||||
}
|
||||
export function bindTruthShipmentSector(f: TruthShipmentSectorFixture, answer: 'yes' | 'no',
|
||||
mixed: unknown = f.mixed, source: Game = f.game, copy: 0 | 1 = 0) {
|
||||
const declared = declareTruthTurnAction(f.base, source, copy).game;
|
||||
const asked = askTruthShipmentSector(declared, f, mixed);
|
||||
const game = actTruthShipmentSector(asked, f.target, { type: 'truthAnswer', answer });
|
||||
return { declared, asked, game };
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
matchesShipment,
|
||||
parseShipmentClaim,
|
||||
parseShipmentExpression,
|
||||
shipmentClaimDestinations,
|
||||
shipmentClaimText,
|
||||
shipmentExpressionOf,
|
||||
ShipmentClaimError,
|
||||
validShipmentClaim,
|
||||
} from '../game/shipment-promises';
|
||||
|
||||
void test('sector-specific flat shipment keeps the selected physical destination', () => {
|
||||
const claim = parseShipmentClaim({ territory: 'wind_pass', minimum: 3, sector: 15 });
|
||||
assert.deepEqual(claim, { territory: 'wind_pass', minimum: 3, sector: 15 });
|
||||
assert.deepEqual(shipmentExpressionOf(claim), claim);
|
||||
assert.equal(shipmentClaimText(claim), 'ship at least 3 physical forces from your reserves to Wind Pass, sector 15');
|
||||
assert.equal(matchesShipment(claim, { territory: 'wind_pass', sector: 15, amount: 3 }), true);
|
||||
assert.equal(matchesShipment(claim, { territory: 'wind_pass', sector: 14, amount: 3 }), false);
|
||||
assert.equal(matchesShipment(claim, { territory: 'wind_pass', amount: 20 }), false);
|
||||
assert.equal(matchesShipment(claim, { territory: 'wind_pass', sector: 15, amount: 2 }), false);
|
||||
assert.equal(matchesShipment(claim, { territory: 'false_wall_west', sector: 15, amount: 3 }), false);
|
||||
assert.equal(matchesShipment(claim, null), false);
|
||||
});
|
||||
|
||||
void test('sector constraints validate against the territory and reject invalid inputs', () => {
|
||||
for (const sector of [14.5, -1, 0, 19, NaN, Infinity, '15', null, undefined]) {
|
||||
assert.throws(() => parseShipmentClaim({ territory: 'wind_pass', minimum: 1, sector }), ShipmentClaimError);
|
||||
assert.equal(validShipmentClaim({ territory: 'wind_pass', minimum: 1, sector }), false);
|
||||
}
|
||||
assert.throws(() => parseShipmentExpression({ territory: 'wind_pass', minimum: 1, sector: 14, extra: true }), ShipmentClaimError);
|
||||
assert.throws(() => parseShipmentClaim({ claim: { territory: 'wind_pass', minimum: 1 }, sector: 14 }), ShipmentClaimError);
|
||||
assert.equal(validShipmentClaim({ territory: 'wind_pass', minimum: 1, sector: 14 }), true);
|
||||
});
|
||||
|
||||
void test('nested sector alternatives share the actual one shipment and retain three-valued facts', () => {
|
||||
const claim = parseShipmentClaim({ claim: { op: 'or', terms: [
|
||||
{ territory: 'wind_pass', minimum: 3, sector: 14 },
|
||||
{ op: 'and', terms: [
|
||||
{ territory: 'wind_pass', minimum: 5, sector: 15 },
|
||||
{ constant: 'unknown' },
|
||||
] },
|
||||
] } }, { allowConstants: true });
|
||||
assert.deepEqual(shipmentClaimDestinations(claim), ['wind_pass']);
|
||||
assert.equal(matchesShipment(claim, { territory: 'wind_pass', sector: 14, amount: 3 }), true);
|
||||
assert.equal(matchesShipment(claim, { territory: 'wind_pass', sector: 15, amount: 5 }), null);
|
||||
assert.equal(matchesShipment(claim, { territory: 'wind_pass', sector: 16, amount: 5 }), false);
|
||||
assert.equal(matchesShipment(claim, { territory: 'wind_pass', amount: 5 }), false);
|
||||
assert.throws(() => shipmentClaimText(claim), ShipmentClaimError);
|
||||
const visible = parseShipmentClaim({ claim: { op: 'and', terms: [
|
||||
{ territory: 'wind_pass', minimum: 1, sector: 14 },
|
||||
{ territory: 'wind_pass', minimum: 2 },
|
||||
] } });
|
||||
assert.match(shipmentClaimText(visible), /Wind Pass, sector 14 AND .*Wind Pass\)/);
|
||||
});
|
||||
|
||||
void test('legacy territory-only claims preserve their exact JSON and match every sector', () => {
|
||||
const input = { territory: 'wind_pass', minimum: 2 };
|
||||
const claim = parseShipmentClaim(input);
|
||||
assert.deepEqual(claim, input);
|
||||
assert.equal(JSON.stringify(shipmentExpressionOf(claim)), JSON.stringify(input));
|
||||
assert.equal(shipmentClaimText(claim), 'ship at least 2 physical forces from your reserves to Wind Pass');
|
||||
assert.equal(matchesShipment(claim, { territory: 'wind_pass', sector: 15, amount: 2 }), true);
|
||||
assert.equal(matchesShipment(claim, { territory: 'wind_pass', sector: 14, amount: 2 }), true);
|
||||
assert.equal(matchesShipment(claim, { territory: 'wind_pass', amount: 2 }), true);
|
||||
assert.equal(matchesShipment(claim, null), false);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { ShipmentClauseFields, invalidShipmentClause, type ShipmentClauseInput } from '../components/shipment-claim-fields';
|
||||
import { shipmentExpressionInputError } from '../game/shipment-expression-input';
|
||||
import type { ShipmentExpression } from '../game/shipment-promises';
|
||||
|
||||
type Element = ReactElement<{ children?: ReactNode; [key: string]: unknown }>;
|
||||
function elements(node: ReactNode): Element[] {
|
||||
if (Array.isArray(node)) return node.flatMap(elements);
|
||||
if (!isValidElement<{ children?: ReactNode; [key: string]: unknown }>(node)) return [];
|
||||
return [node, ...elements(node.props.children)];
|
||||
}
|
||||
void test('standalone shipment editor defaults to any sector and accepts only printed sectors', () => {
|
||||
let value: ShipmentClauseInput = { territory: 'wind_pass', minimum: 2 };
|
||||
const tree = () => ShipmentClauseFields({ id: 'shipment', value, onChange(next) { value = next; } });
|
||||
const select = (suffix: string, chosen: string) => {
|
||||
const element = elements(tree()).find(item => item.props.id === `shipment-${suffix}`); assert.ok(element);
|
||||
(element.props.onChange as (event: { currentTarget: { value: string }; target: { value: string } }) => void)(
|
||||
{ currentTarget: { value: chosen }, target: { value: chosen } });
|
||||
};
|
||||
const html = renderToStaticMarkup(createElement(ShipmentClauseFields, { id: 'shipment', value, onChange() {} }));
|
||||
assert.match(html, /Any sector in this territory/);
|
||||
assert.match(html, /Sector 14/);
|
||||
assert.doesNotMatch(html, /Sector 9/);
|
||||
select('sector', '15'); assert.deepEqual(value, { territory: 'wind_pass', minimum: 2, sector: 15 });
|
||||
assert.equal(invalidShipmentClause(value), false);
|
||||
select('sector', '19'); assert.deepEqual(value, { territory: 'wind_pass', minimum: 2, sector: 15 });
|
||||
select('sector', ''); assert.deepEqual(value, { territory: 'wind_pass', minimum: 2 });
|
||||
select('sector', '14'); select('territory', 'arrakeen');
|
||||
assert.deepEqual(value, { territory: 'arrakeen', minimum: 2 });
|
||||
});
|
||||
|
||||
void test('sector input validation checks the actual territory while preserving incomplete legacy drafts', () => {
|
||||
const exact: ShipmentExpression = { territory: 'wind_pass', minimum: 2, sector: 15 };
|
||||
assert.equal(shipmentExpressionInputError(exact), null);
|
||||
assert.notEqual(shipmentExpressionInputError({ ...exact, sector: 9 }), null);
|
||||
assert.equal(invalidShipmentClause({ territory: 'wind_pass', minimum: 2, sector: 9 }), true);
|
||||
assert.equal(invalidShipmentClause({ territory: 'wind_pass', minimum: 0 }), true);
|
||||
// Structural input checks intentionally allow ordinary fields to be repaired.
|
||||
assert.equal(shipmentExpressionInputError({ territory: '', minimum: 0 }), null);
|
||||
assert.equal(shipmentExpressionInputError({ op: 'or', terms: [exact, { territory: 'carthag', minimum: 1 }] }), null);
|
||||
assert.notEqual(shipmentExpressionInputError({ op: 'and', terms: [exact,
|
||||
{ territory: 'wind_pass', minimum: 2, sector: 3 }] }), null);
|
||||
});
|
||||
|
||||
void test('mixed-shipment fields remain on their existing territory-only question interface', () => {
|
||||
const mixed = { kind: 'shipment' as const, territory: 'wind_pass', minimum: 2 };
|
||||
let result: ShipmentClauseInput = mixed;
|
||||
const tree = () => ShipmentClauseFields({ id: 'mixed', value: mixed, onChange(next) { result = next; } });
|
||||
assert.equal(elements(tree()).some(item => item.props.id === 'mixed-sector'), false);
|
||||
const territory = elements(tree()).find(item => item.props.id === 'mixed-territory'); assert.ok(territory);
|
||||
(territory.props.onChange as (event: { target: { value: string } }) => void)({ target: { value: 'arrakeen' } });
|
||||
assert.deepEqual(result, { kind: 'shipment', territory: 'arrakeen', minimum: 2 });
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { TravelClaimFields, travelClaimInputError } from '../components/travel-claim-fields';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type GameView } from '../game/engine';
|
||||
import type { MixedActionExpression } from '../game/mixed-action-question';
|
||||
import type { TravelActionClaim } from '../game/travel-expression-promises';
|
||||
|
||||
type Expression = MixedActionExpression<TravelActionClaim>;
|
||||
type Element = ReactElement<{ children?: ReactNode; [key: string]: unknown }>;
|
||||
const ship = (sector?: number): Expression => ({ kind: 'action', action: {
|
||||
kind: 'ship', territory: 'imperial_basin', minimum: 1, ...(sector === undefined ? {} : { sector }),
|
||||
} });
|
||||
/** Controlled public projection exercises the editor, not the server's phase eligibility. */
|
||||
function projection(): GameView {
|
||||
const game = createGame('TRAVELSECTOR', newPlayer('p', 'Atreides', 'atreides'));
|
||||
joinGame(game, newPlayer('a', 'Emperor', 'emperor'));
|
||||
const view = viewGame(game, 'p');
|
||||
view.status = 'playing'; view.turn = 1; view.phase = 5; view.active = 'p';
|
||||
return view;
|
||||
}
|
||||
function elements(node: ReactNode): Element[] {
|
||||
if (Array.isArray(node)) return node.flatMap(elements);
|
||||
if (!isValidElement<{ children?: ReactNode; [key: string]: unknown }>(node)) return [];
|
||||
return [node, ...elements(node.props.children)];
|
||||
}
|
||||
void test('travel shipment editor chooses a printed sector or any sector and resets it on territory changes', () => {
|
||||
const game = projection(); let value: Expression = ship();
|
||||
const tree = () => TravelClaimFields({ game, value, target: 'p', disabled: false, onChange(next) { value = next; } });
|
||||
const select = (suffix: string, chosen: string) => {
|
||||
const element = elements(tree()).find(item => item.props.id === `truth-travel-1-${suffix}`); assert.ok(element);
|
||||
(element.props.onChange as (event: { currentTarget: { value: string } }) => void)({ currentTarget: { value: chosen } });
|
||||
};
|
||||
assert.equal(travelClaimInputError(value, game, 'p'), null);
|
||||
const html = renderToStaticMarkup(createElement(TravelClaimFields, { game, value, target: 'p', disabled: false, onChange() {} }));
|
||||
assert.match(html, /Any sector in this territory/);
|
||||
assert.match(html, /Sector 9/);
|
||||
select('sector', '9'); assert.deepEqual(value, ship(9));
|
||||
assert.equal(travelClaimInputError(value, game, 'p'), null);
|
||||
select('sector', '18'); assert.deepEqual(value, ship(9));
|
||||
assert.notEqual(travelClaimInputError(ship(18), game, 'p'), null);
|
||||
select('sector', ''); assert.deepEqual(value, ship());
|
||||
select('sector', '10'); select('territory', 'arrakeen');
|
||||
assert.deepEqual(value, { kind: 'action', action: { kind: 'ship', territory: 'arrakeen', minimum: 1 } });
|
||||
});
|
||||
|
||||
void test('switching to movement removes the shipment-only sector and disabled fields cannot change the question', () => {
|
||||
const game = projection(); let value: Expression = ship(9);
|
||||
const tree = (disabled = false) => TravelClaimFields({ game, value, target: 'p', disabled, onChange(next) { value = next; } });
|
||||
const action = () => {
|
||||
const element = elements(tree()).find(item => item.props.id === 'truth-travel-1-action'); assert.ok(element);
|
||||
return element;
|
||||
};
|
||||
(action().props.onChange as (event: { currentTarget: { value: string } }) => void)({ currentTarget: { value: 'move' } });
|
||||
assert.deepEqual(value, { kind: 'action', action: { kind: 'move', territory: 'imperial_basin', minimum: 1 } });
|
||||
assert.equal(travelClaimInputError(value, game, 'p'), null);
|
||||
assert.equal(elements(tree()).some(item => item.props.id === 'truth-travel-1-sector'), false);
|
||||
(action().props.onChange as (event: { currentTarget: { value: string } }) => void)({ currentTarget: { value: 'ship' } });
|
||||
assert.deepEqual(value, ship());
|
||||
const disabledSector = elements(tree(true)).find(item => item.props.id === 'truth-travel-1-sector'); assert.ok(disabledSector);
|
||||
(disabledSector.props.onChange as (event: { currentTarget: { value: string } }) => void)({ currentTarget: { value: '9' } });
|
||||
assert.deepEqual(value, ship());
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { TERRITORIES } from '../game/board';
|
||||
import { compileMixedActionExpression, type MixedActionExpression } from '../game/mixed-action-question';
|
||||
import {
|
||||
parseTravelActionClaim, recordTravelShipment, travelBranches, travelPromiseValue, validateTravelPromises,
|
||||
type TravelActionClaim, type TravelPromise,
|
||||
} from '../game/travel-expression-promises';
|
||||
|
||||
const destination = TERRITORIES.find(territory => territory.sectors.length >= 2)!;
|
||||
assert.ok(destination);
|
||||
const [first, second] = destination.sectors;
|
||||
const ship = (minimum: number, sector?: number): MixedActionExpression<TravelActionClaim> => ({
|
||||
kind: 'action', action: { kind: 'ship', territory: destination.id, minimum,
|
||||
...(sector === undefined ? {} : { sector }) },
|
||||
});
|
||||
const group = (kind: 'and' | 'or', ...terms: MixedActionExpression<TravelActionClaim>[]): MixedActionExpression<TravelActionClaim> =>
|
||||
({ kind, terms });
|
||||
function promise(mixed: MixedActionExpression<TravelActionClaim>, answer = true): TravelPromise {
|
||||
return { turn: 1, mixed, event: 'travel', player: 'owner', asker: 'asker', answer,
|
||||
compiled: compileMixedActionExpression(mixed, () => 'yes', answer),
|
||||
shipment: { pending: true, event: null }, progress: [], movementClosed: false,
|
||||
fulfilled: false, released: false };
|
||||
}
|
||||
|
||||
void test('only a physical reserve shipment may name a real printed destination sector', () => {
|
||||
const input = { kind: 'ship', territory: destination.id, minimum: 2, sector: first };
|
||||
assert.deepEqual(parseTravelActionClaim(input), input);
|
||||
assert.deepEqual(parseTravelActionClaim({ kind: 'ship', territory: destination.id, minimum: 2 }),
|
||||
{ kind: 'ship', territory: destination.id, minimum: 2 });
|
||||
for (const value of [
|
||||
{ ...input, kind: 'move' }, { ...input, sector: -1 }, { ...input, sector: 1.2 },
|
||||
{ ...input, sector: undefined }, { ...input, minimum: 0 }, { ...input, extra: true },
|
||||
{ ...input, sector: Math.max(...destination.sectors) + 100 },
|
||||
]) assert.throws(() => parseTravelActionClaim(value));
|
||||
});
|
||||
|
||||
void test('sector claims compare actual destination and counts without inventing a legacy sector', () => {
|
||||
const row = promise(ship(2, first));
|
||||
const [correct] = recordTravelShipment([row], 'owner', 1, { territory: destination.id, amount: 2, sector: first });
|
||||
assert.equal(correct.fulfilled, true);
|
||||
assert.equal(travelPromiseValue(correct), true);
|
||||
assert.deepEqual(correct.shipment.event, { territory: destination.id, amount: 2, sector: first });
|
||||
const [otherSector] = recordTravelShipment([row], 'owner', 1, { territory: destination.id, amount: 2, sector: second });
|
||||
assert.equal(otherSector.fulfilled, false);
|
||||
assert.equal(travelPromiseValue(otherSector), false);
|
||||
const [legacy] = recordTravelShipment([row], 'owner', 1, { territory: destination.id, amount: 2 });
|
||||
assert.equal(travelPromiseValue(legacy), false);
|
||||
assert.deepEqual(legacy.shipment.event, { territory: destination.id, amount: 2 });
|
||||
validateTravelPromises([JSON.parse(JSON.stringify(correct))], ['owner', 'asker']);
|
||||
});
|
||||
|
||||
void test('sectorless original questions retain their original serialized receipt shape', () => {
|
||||
const row = promise(ship(2));
|
||||
const [settled] = recordTravelShipment([row], 'owner', 1, { territory: destination.id, amount: 2, sector: first });
|
||||
assert.equal(settled.fulfilled, true);
|
||||
assert.deepEqual(settled.shipment.event, { territory: destination.id, amount: 2 });
|
||||
validateTravelPromises([JSON.parse(JSON.stringify(settled))], ['owner', 'asker']);
|
||||
});
|
||||
|
||||
void test('a whole No blocks the matching sector and permits another legal sector or no shipment', () => {
|
||||
const row = promise(ship(2, first), false);
|
||||
assert.throws(() => recordTravelShipment([row], 'owner', 1, { territory: destination.id, amount: 2, sector: first }));
|
||||
const [different] = recordTravelShipment([row], 'owner', 1, { territory: destination.id, amount: 2, sector: second });
|
||||
assert.equal(travelPromiseValue(different), false);
|
||||
const [absent] = recordTravelShipment([row], 'owner', 1, null);
|
||||
assert.equal(travelPromiseValue(absent), false);
|
||||
});
|
||||
|
||||
void test('AND and No alternatives retain single-shipment sector correlations', () => {
|
||||
const incompatible = promise(group('and', ship(1, first), ship(1, second)));
|
||||
assert.deepEqual([...travelBranches(incompatible)], []);
|
||||
const compatible = promise(group('and', ship(2), ship(3, first)));
|
||||
const branches = [...travelBranches(compatible)];
|
||||
assert.equal(branches.length, 1);
|
||||
assert.equal(branches[0].length, 2);
|
||||
const alternative = promise(group('and', ship(2), ship(3, first)), false);
|
||||
assert.equal([...travelBranches(alternative)].length, 2);
|
||||
const mixed = promise(group('and', ship(3, first), ship(2)), false);
|
||||
const [matching] = recordTravelShipment([mixed], 'owner', 1, { territory: destination.id, amount: 3, sector: second });
|
||||
assert.equal(travelPromiseValue(matching), false);
|
||||
});
|
||||
|
||||
void test('stored sector receipts reject mismatched or invented printed sectors', () => {
|
||||
const row = promise(ship(2, first));
|
||||
for (const event of [
|
||||
{ territory: destination.id, amount: 1, sector: -1 },
|
||||
{ territory: destination.id, amount: 1, sector: Math.max(...destination.sectors) + 100 },
|
||||
{ territory: destination.id, amount: 1, sector: first, extra: true },
|
||||
]) assert.throws(() => recordTravelShipment([row], 'owner', 1, event));
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { bindSectorGuildGuard as bind, createSectorGuildGuardFixture as fixture } from './fixture-truth-shipment-sector-guards';
|
||||
|
||||
function rejected(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
function promise(game: Game, player: string) {
|
||||
const row = game.turnActionPromises?.find(item => item.player === player); assert.ok(row); return row;
|
||||
}
|
||||
|
||||
void test('an exact-sector Yes guards actual Fremen allied Guild reserve transport before commitment', () => {
|
||||
const f = fixture(); let game = bind(f);
|
||||
rejected(game, f.target, f.ship('imperial_basin', 10));
|
||||
rejected(game, f.target, f.ship('carthag', 11));
|
||||
assert.equal(game.players.find(player => player.id === f.target)?.reserves, 19);
|
||||
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type, 'guildShip'); assert.equal(guide.action.from, 'reserves');
|
||||
assert.equal(guide.action.territory, 'imperial_basin'); assert.equal(guide.action.sector, 9);
|
||||
const beforeSpice = game.players.find(player => player.id === f.target)!.spice;
|
||||
game = applyAction(game, f.target, guide.action);
|
||||
assert.equal(game.players.find(player => player.id === f.target)?.forces['imperial_basin:9'], 1);
|
||||
assert.equal(game.players.find(player => player.id === f.target)?.reserves, 18);
|
||||
assert.equal(game.players.find(player => player.id === f.target)?.spice, beforeSpice - 1);
|
||||
assert.equal(promise(game, f.target).shipment.event?.sector, 9); assert.equal(promise(game, f.target).fulfilled, true);
|
||||
assert.deepEqual(f.inventory(game), f.original);
|
||||
});
|
||||
|
||||
void test('starting movement cannot forfeit an exact-sector Yes shipment', () => {
|
||||
const f = fixture(); let game = bind(f);
|
||||
rejected(game, f.target, f.movement);
|
||||
rejected(game, f.target, f.ship('imperial_basin', 10));
|
||||
game = applyAction(game, f.target, f.ship('imperial_basin', 9));
|
||||
assert.equal(promise(game, f.target).shipment.event?.sector, 9); assert.equal(promise(game, f.target).fulfilled, true);
|
||||
assert.deepEqual(f.inventory(game), f.original);
|
||||
});
|
||||
|
||||
void test('a valid movement OR fulfills the answer while forfeiting the unused shipment', () => {
|
||||
const f = fixture();
|
||||
const mixed = { kind: 'or', terms: [f.claim, { kind: 'action', action: { kind: 'move', territory: 'oh_gap', minimum: 1 } }] };
|
||||
let game = bind(f, mixed);
|
||||
game = applyAction(game, f.target, f.movement);
|
||||
assert.equal(promise(game, f.target).fulfilled, true);
|
||||
rejected(game, f.target, f.ship('imperial_basin', 10));
|
||||
assert.equal(game.players.find(player => player.id === f.target)?.forces['oh_gap:9'], 1);
|
||||
assert.deepEqual(f.inventory(game), f.original);
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { TurnActionGuideButton } from '../components/turn-action-promises';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import { declareTruthTurnAction, settleTruthTurnAction, truthTurnActionInventory, truthTurnActionPlayer } from './fixture-truth-turn-action';
|
||||
import {
|
||||
actTruthShipmentSector as act, askTruthShipmentSector as ask, bindTruthShipmentSector as bind,
|
||||
createTruthShipmentSectorFixture as fixture, sectorShipment,
|
||||
} from './fixture-truth-shipment-sector';
|
||||
|
||||
function rejected(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
function row(game: Game, player: string) {
|
||||
const promise = game.turnActionPromises?.find(item => item.player === player);
|
||||
assert.ok(promise); return promise;
|
||||
}
|
||||
|
||||
void test('Yes binds an actual original reserve shipment to the exact sector and shares its private native guide', () => {
|
||||
const f = fixture(), asked = bind(f, 'yes'); let game = asked.game;
|
||||
assert.ok(viewGame(asked.asked, f.target).truthTurnActionAnswers?.includes('yes'));
|
||||
assert.deepEqual(row(game, f.target).mixed, f.mixed);
|
||||
assert.ok(!viewGame(game, f.observer).turnActionPromises.some(item => item.player === f.target));
|
||||
rejected(game, f.target, f.shipment(f.alternative));
|
||||
rejected(game, f.target, { type: 'endMovement' });
|
||||
const view = viewGame(game, f.target), guide = view.turnActionGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type, 'ship'); assert.equal(guide.action.territory, f.territory);
|
||||
assert.equal(guide.action.sector, f.sector); assert.equal(guide.action.amount, 1);
|
||||
const sent: Action[] = [], props = { game: view, busy: false, act: (next: Action) => sent.push(next) };
|
||||
const button = TurnActionGuideButton(props); assert.ok(button); assert.equal(button.props.disabled, false);
|
||||
assert.match(renderToStaticMarkup(createElement(TurnActionGuideButton, props)), /ship|shipment/i);
|
||||
button.props.onClick(); assert.deepEqual(sent, [guide.action]);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const bot = viewGame(game, f.target);
|
||||
bot.players.find(player => player.id === f.target)!.bot = difficulty;
|
||||
assert.deepEqual(botActions(bot)[0], guide.action);
|
||||
}
|
||||
const spice = truthTurnActionPlayer(game, f.target).spice, reserves = truthTurnActionPlayer(game, f.target).reserves;
|
||||
game = settleTruthTurnAction(act(game, f.target, guide.action));
|
||||
assert.equal(truthTurnActionPlayer(game, f.target).forces[`${f.territory}:${f.sector}`], 1);
|
||||
assert.equal(truthTurnActionPlayer(game, f.target).reserves, reserves - 1);
|
||||
assert.equal(truthTurnActionPlayer(game, f.target).spice, spice - 2);
|
||||
assert.equal(row(game, f.target).shipment.event?.sector, f.sector);
|
||||
assert.equal(row(game, f.target).fulfilled, true);
|
||||
assert.equal(game.discard.filter(card => card.id === f.truths[0]).length, 1);
|
||||
assert.deepEqual(truthTurnActionInventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('No forbids only the named sector and permits another legal sector of the same territory', () => {
|
||||
const f = fixture(); let game = bind(f, 'no').game;
|
||||
rejected(game, f.target, f.shipment(f.sector));
|
||||
game = settleTruthTurnAction(act(game, f.target, f.shipment(f.alternative)));
|
||||
assert.equal(truthTurnActionPlayer(game, f.target).forces[`${f.territory}:${f.alternative}`], 1);
|
||||
assert.equal(row(game, f.target).shipment.event?.sector, f.alternative);
|
||||
assert.equal(row(game, f.target).fulfilled, true);
|
||||
assert.deepEqual(truthTurnActionInventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('invalid printed sector is rejected without consuming the Truthtrance', () => {
|
||||
const f = fixture(), declared = declareTruthTurnAction(f.base).game;
|
||||
rejected(declared, f.asker, { type: 'truthAsk', question: { kind: 'turnActions', target: f.target,
|
||||
turn: declared.turn, mixed: sectorShipment(f.territory, 0) } });
|
||||
assert.ok(truthTurnActionPlayer(declared, f.asker).hand.some(card => card.id === f.truths[0]));
|
||||
});
|
||||
|
||||
void test('two original Truthtrances cannot require two different sectors in the single shipment', () => {
|
||||
const f = fixture(); let game = bind(f, 'yes').game;
|
||||
game = declareTruthTurnAction(f.base, game, 1).game;
|
||||
game = ask(game, f, sectorShipment(f.territory, f.alternative));
|
||||
const answers = viewGame(game, f.target).truthTurnActionAnswers;
|
||||
assert.ok(answers?.includes('no')); assert.ok(!answers?.includes('yes'));
|
||||
rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer: 'no' });
|
||||
rejected(game, f.target, f.shipment(f.alternative));
|
||||
game = settleTruthTurnAction(act(game, f.target, f.shipment(f.sector)));
|
||||
assert.equal(game.turnActionPromises?.filter(item => item.player === f.target).length, 2);
|
||||
assert.ok(game.turnActionPromises?.filter(item => item.player === f.target).every(item => item.fulfilled));
|
||||
assert.deepEqual(truthTurnActionInventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('an OR answer can be satisfied by either exact sector without inventing another shipment', () => {
|
||||
const f = fixture();
|
||||
const mixed = { kind: 'or', terms: [f.mixed, sectorShipment(f.territory, f.alternative)] };
|
||||
let game = bind(f, 'yes', mixed).game;
|
||||
game = settleTruthTurnAction(act(game, f.target, f.shipment(f.alternative)));
|
||||
assert.equal(row(game, f.target).fulfilled, true);
|
||||
assert.equal(row(game, f.target).shipment.event?.sector, f.alternative);
|
||||
assert.equal(truthTurnActionPlayer(game, f.target).reserves, 9);
|
||||
assert.deepEqual(truthTurnActionInventory(game), f.originalCards);
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { ShipmentPromises } from '../components/shipment-promises';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import { liveShipmentPromises, matchesShipment } from '../game/shipment-promises';
|
||||
import { declareTruthTurnAction, settleTruthTurnAction, truthTurnActionInventory, truthTurnActionPlayer } from './fixture-truth-turn-action';
|
||||
import { actTruthShipmentSector as act, createTruthShipmentSectorFixture as fixture,
|
||||
type TruthShipmentSectorFixture } from './fixture-truth-shipment-sector';
|
||||
|
||||
function asked(f: TruthShipmentSectorFixture): Game {
|
||||
const declared = declareTruthTurnAction(f.base).game;
|
||||
return act(declared, f.asker, { type: 'truthAsk', question: {
|
||||
kind: 'shipment', target: f.target, territory: f.territory, minimum: 1, sector: f.sector,
|
||||
} });
|
||||
}
|
||||
function rejected(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
|
||||
void test('standalone exact-sector shipment Yes exposes the native witness and all bot profiles honor it', () => {
|
||||
const f = fixture(), question = asked(f);
|
||||
assert.ok(viewGame(question, f.target).truthShipmentAnswers?.includes('yes'));
|
||||
let game = act(question, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
assert.equal(game.shipmentPromises?.[0].sector, f.sector);
|
||||
const view = viewGame(game, f.target), witness = view.shipmentCompletion?.actions[0]; assert.ok(witness);
|
||||
assert.equal(witness.type, 'ship'); assert.equal(witness.territory, f.territory); assert.equal(witness.sector, f.sector);
|
||||
assert.ok(Number(witness.amount) >= 1);
|
||||
const html = renderToStaticMarkup(createElement(ShipmentPromises, { game: view, busy: false, act() {} }));
|
||||
assert.match(html, /Suggested next step/); assert.match(html, new RegExp(`sector ${f.sector}`));
|
||||
assert.match(html, /Cost:/);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const bot = viewGame(game, f.target); bot.players.find(player => player.id === f.target)!.bot = difficulty;
|
||||
assert.deepEqual(botActions(bot)[0], witness);
|
||||
}
|
||||
rejected(game, f.target, f.shipment(f.alternative));
|
||||
const beforeSpice = truthTurnActionPlayer(game, f.target).spice, beforeReserves = truthTurnActionPlayer(game, f.target).reserves;
|
||||
game = settleTruthTurnAction(act(game, f.target, witness));
|
||||
const count = Number(witness.amount);
|
||||
assert.equal(truthTurnActionPlayer(game, f.target).forces[`${f.territory}:${f.sector}`], count);
|
||||
assert.equal(truthTurnActionPlayer(game, f.target).reserves, beforeReserves - count);
|
||||
assert.equal(truthTurnActionPlayer(game, f.target).spice, beforeSpice - count * 2);
|
||||
assert.equal(game.shipmentPromises?.[0].fulfilled, true);
|
||||
assert.deepEqual(truthTurnActionInventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('standalone No excludes the named sector, permits another sector and keeps all bot candidates legal', () => {
|
||||
const f = fixture(), question = asked(f);
|
||||
assert.ok(viewGame(question, f.target).truthShipmentAnswers?.includes('no'));
|
||||
let game = act(question, f.target, { type: 'truthAnswer', answer: 'no' });
|
||||
const promises = liveShipmentPromises(game.shipmentPromises ?? [], f.target, game.turn);
|
||||
assert.equal(promises.length, 1); assert.equal(promises[0].sector, f.sector);
|
||||
rejected(game, f.target, f.shipment(f.sector));
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const bot = viewGame(game, f.target); bot.players.find(player => player.id === f.target)!.bot = difficulty;
|
||||
for (const action of botActions(bot)) {
|
||||
if (action.type !== 'ship' && !(action.type === 'guildShip' && action.from === 'reserves')) continue;
|
||||
const shipment = { territory: String(action.territory), sector: Number(action.sector), amount: Number(action.amount) };
|
||||
assert.ok(promises.every(promise => matchesShipment(promise, shipment) === promise.answer));
|
||||
}
|
||||
}
|
||||
const beforeSpice = truthTurnActionPlayer(game, f.target).spice;
|
||||
game = settleTruthTurnAction(act(game, f.target, f.shipment(f.alternative)));
|
||||
assert.equal(truthTurnActionPlayer(game, f.target).forces[`${f.territory}:${f.alternative}`], 1);
|
||||
assert.equal(truthTurnActionPlayer(game, f.target).spice, beforeSpice - 2);
|
||||
assert.equal(game.shipmentPromises?.[0].fulfilled, true);
|
||||
assert.deepEqual(truthTurnActionInventory(game), f.originalCards);
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { TravelPromiseGuideButton } from '../components/travel-promises';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import { declareTruthTurnAction, settleTruthTurnAction, truthTurnActionInventory, truthTurnActionPlayer } from './fixture-truth-turn-action';
|
||||
import {
|
||||
actTruthShipmentSector as act, createTruthShipmentSectorFixture as fixture,
|
||||
sectorShipment, type TruthShipmentSectorFixture,
|
||||
} from './fixture-truth-shipment-sector';
|
||||
|
||||
/** Uses the genuine Basic fixture's original setup, dealt Truth and actual
|
||||
* Shipment and Movement turn; only its real native sectors are selected. */
|
||||
function bind(f: TruthShipmentSectorFixture, answer: 'yes' | 'no', mixed: unknown = f.mixed) {
|
||||
const declared = declareTruthTurnAction(f.base).game;
|
||||
const asked = act(declared, f.asker, { type: 'truthAsk', question: {
|
||||
kind: 'travel', target: f.target, turn: declared.turn, mixed: structuredClone(mixed),
|
||||
} } as Action);
|
||||
assert.ok(viewGame(asked, f.target).truthTravelAnswers?.includes(answer));
|
||||
const game = act(asked, f.target, { type: 'truthAnswer', answer });
|
||||
return { asked, game };
|
||||
}
|
||||
function rejected(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
function row(game: Game, owner: string) {
|
||||
const result = game.travelPromises?.find(promise => promise.player === owner);
|
||||
assert.ok(result); return result;
|
||||
}
|
||||
|
||||
void test('standalone travel Yes binds the one native reserve shipment to the exact sector', () => {
|
||||
const f = fixture(), bound = bind(f, 'yes'); let game = bound.game;
|
||||
assert.deepEqual(row(game, f.target).mixed, f.mixed);
|
||||
assert.ok(!viewGame(game, f.observer).travelPromises.some(promise => promise.player === f.target));
|
||||
rejected(game, f.target, f.shipment(f.alternative));
|
||||
rejected(game, f.target, { type: 'endMovement' });
|
||||
const view = viewGame(game, f.target), guide = view.travelPromiseGuide;
|
||||
assert.ok(guide); assert.equal(guide.action.type, 'ship');
|
||||
assert.equal(guide.action.territory, f.territory); assert.equal(guide.action.sector, f.sector);
|
||||
const sent: Action[] = [], props = { game: view, busy: false, act: (next: Action) => sent.push(next) };
|
||||
const button = TravelPromiseGuideButton(props); assert.ok(button); assert.equal(button.props.disabled, false);
|
||||
assert.match(renderToStaticMarkup(createElement(TravelPromiseGuideButton, props)), new RegExp(`sector ${f.sector}`, 'i'));
|
||||
button.props.onClick(); assert.deepEqual(sent, [guide.action]);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const bot = viewGame(game, f.target);
|
||||
bot.players.find(player => player.id === f.target)!.bot = difficulty;
|
||||
assert.deepEqual(botActions(bot)[0], guide.action);
|
||||
}
|
||||
const before = truthTurnActionPlayer(game, f.target);
|
||||
const reserves = before.reserves, spice = before.spice;
|
||||
game = settleTruthTurnAction(act(game, f.target, guide.action));
|
||||
assert.equal(row(game, f.target).shipment.event?.sector, f.sector);
|
||||
assert.equal(row(game, f.target).fulfilled, true);
|
||||
assert.equal(truthTurnActionPlayer(game, f.target).forces[`${f.territory}:${f.sector}`], 1);
|
||||
assert.equal(truthTurnActionPlayer(game, f.target).reserves, reserves - 1);
|
||||
assert.equal(truthTurnActionPlayer(game, f.target).spice, spice - 2);
|
||||
assert.equal(game.discard.filter(card => card.id === f.truths[0]).length, 1);
|
||||
assert.deepEqual(truthTurnActionInventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('standalone travel No forbids the named sector and permits another real sector', () => {
|
||||
const f = fixture(); let game = bind(f, 'no').game;
|
||||
rejected(game, f.target, f.shipment(f.sector));
|
||||
game = settleTruthTurnAction(act(game, f.target, f.shipment(f.alternative)));
|
||||
assert.equal(truthTurnActionPlayer(game, f.target).forces[`${f.territory}:${f.alternative}`], 1);
|
||||
assert.equal(row(game, f.target).shipment.event?.sector, f.alternative);
|
||||
game = settleTruthTurnAction(act(game, f.target, { type: 'endMovement' }));
|
||||
assert.equal(row(game, f.target).fulfilled, true);
|
||||
assert.deepEqual(truthTurnActionInventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('standalone travel OR accepts either actual sector through the single shipment', () => {
|
||||
const f = fixture();
|
||||
const mixed = { kind: 'or', terms: [f.mixed, sectorShipment(f.territory, f.alternative)] };
|
||||
const bound = bind(f, 'yes', mixed).game;
|
||||
for (const sector of [f.sector, f.alternative]) {
|
||||
const game = settleTruthTurnAction(act(bound, f.target, f.shipment(sector)));
|
||||
assert.equal(row(game, f.target).fulfilled, true);
|
||||
assert.equal(row(game, f.target).shipment.event?.sector, sector);
|
||||
assert.equal(truthTurnActionPlayer(game, f.target).reserves, 9);
|
||||
assert.deepEqual(truthTurnActionInventory(game), f.originalCards);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { TurnActionFields, turnActionInputError } from '../components/turn-action-fields';
|
||||
import { TurnActionGuideButton } from '../components/turn-action-promises';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
|
||||
import type { MixedActionExpression } from '../game/mixed-action-question';
|
||||
import type { TurnActionLeaf } from '../game/turn-action-promises';
|
||||
|
||||
type Expression = MixedActionExpression<TurnActionLeaf>;
|
||||
type Element = ReactElement<{ children?: ReactNode; [key: string]: unknown }>;
|
||||
const ship = (sector?: number): Expression => ({ kind: 'action', action: {
|
||||
kind: 'ship', territory: 'imperial_basin', minimum: 1, ...(sector === undefined ? {} : { sector }),
|
||||
} });
|
||||
/** Controlled public projection: control validation, not native setup. */
|
||||
function projection(): GameView {
|
||||
const game = createGame('SECTORCONTROLS', newPlayer('p', 'Atreides', 'atreides'));
|
||||
joinGame(game, newPlayer('a', 'Emperor', 'emperor'));
|
||||
const view = viewGame(game, 'p');
|
||||
view.status = 'playing'; view.turn = 1; view.phase = 5; view.active = 'p';
|
||||
view.movementRemaining = ['p']; view.storm = 1;
|
||||
const me = view.players.find(player => player.id === 'p'); assert.ok(me);
|
||||
me.shipped = false; me.reserves = 10; me.spice = 10;
|
||||
return view;
|
||||
}
|
||||
function elements(node: ReactNode): Element[] {
|
||||
if (Array.isArray(node)) return node.flatMap(elements);
|
||||
if (!isValidElement<{ children?: ReactNode; [key: string]: unknown }>(node)) return [];
|
||||
return [node, ...elements(node.props.children)];
|
||||
}
|
||||
void test('shipment editor supports any printed sector or a specific sector and clears it on territory change', () => {
|
||||
const game = projection(); let value: Expression = ship();
|
||||
const tree = () => TurnActionFields({ game, value, target: 'p', disabled: false, onChange(next) { value = next; } });
|
||||
const select = (suffix: string, chosen: string) => {
|
||||
const element = elements(tree()).find(item => item.props.id === `truth-turn-action-1-${suffix}`); assert.ok(element);
|
||||
(element.props.onChange as (event: { currentTarget: { value: string } }) => void)({ currentTarget: { value: chosen } });
|
||||
};
|
||||
assert.equal(turnActionInputError(value, game, 'p'), null);
|
||||
assert.match(renderToStaticMarkup(createElement(TurnActionFields, { game, value, target: 'p', disabled: false, onChange() {} })), /Any sector in this territory/);
|
||||
select('sector', '9'); assert.deepEqual(value, ship(9)); assert.equal(turnActionInputError(value, game, 'p'), null);
|
||||
assert.notEqual(turnActionInputError(ship(0), game, 'p'), null);
|
||||
select('sector', ''); assert.deepEqual(value, ship());
|
||||
select('sector', '10'); select('territory', 'arrakeen');
|
||||
assert.deepEqual(value, { kind: 'action', action: { kind: 'ship', territory: 'arrakeen', minimum: 1 } });
|
||||
});
|
||||
void test('the native exact-sector guide dispatches its quoted shipment and rejects stale sources', () => {
|
||||
for (const mutate of [
|
||||
(view: GameView) => { view.storm = 9; },
|
||||
(view: GameView) => { view.players.find(player => player.id === 'p')!.shipped = true; },
|
||||
(view: GameView) => { view.players.find(player => player.id === 'p')!.reserves = 0; },
|
||||
(view: GameView) => { view.players.find(player => player.id === 'p')!.spice = 0; },
|
||||
(view: GameView) => { view.players.find(player => player.id === 'a')!.forces = { 'imperial_basin:9': 1 }; },
|
||||
]) {
|
||||
const game = projection(), event = 'controlled-sector-promise', mixed = ship(9);
|
||||
game.turnActionPromises = [{ event, player: 'p', asker: 'a', turn: 1, mixed, answer: true, fulfilled: false, released: false }];
|
||||
const action: Action = { type: 'ship', territory: 'imperial_basin', sector: 9, amount: 1, elite: 0, allyPayment: 0,
|
||||
turnActionPromiseEvent: event, turnActionPromisePhase: 5 };
|
||||
game.turnActionGuide = { event, action };
|
||||
const sent: Action[] = [], props = { game, busy: false, act: (next: Action) => sent.push(next) };
|
||||
const button = TurnActionGuideButton(props); assert.ok(button); assert.equal(button.props.disabled, false);
|
||||
assert.match(renderToStaticMarkup(createElement(TurnActionGuideButton, props)), /Imperial Basin, sector 9/);
|
||||
button.props.onClick(); assert.deepEqual(sent, [action]);
|
||||
mutate(game); button.props.onClick(); assert.deepEqual(sent, [action]);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { compileMixedActionExpression, type MixedActionExpression } from '../game/mixed-action-question';
|
||||
import {
|
||||
TurnActionError,
|
||||
parseTurnActionLeaf,
|
||||
recordTurnActionShipment,
|
||||
turnActionBranches,
|
||||
turnActionValue,
|
||||
validateTurnActionPromises,
|
||||
type TurnActionLeaf,
|
||||
type TurnActionPromise,
|
||||
} from '../game/turn-action-promises';
|
||||
|
||||
type Expression = MixedActionExpression<TurnActionLeaf>;
|
||||
const ship = (sector?: number, minimum = 3, territory = 'wind_pass'): Expression => ({
|
||||
kind: 'action', action: { kind: 'ship', territory, minimum, ...(sector === undefined ? {} : { sector }) },
|
||||
});
|
||||
const group = (kind: 'and' | 'or', ...terms: Expression[]): Expression => ({ kind, terms });
|
||||
function promise(mixed: Expression, answer = true): TurnActionPromise {
|
||||
return {
|
||||
turn: 1, mixed, event: 'whole:1', player: 'p', asker: 'a', answer,
|
||||
compiled: compileMixedActionExpression(mixed, () => 'yes', answer),
|
||||
shipment: { pending: true, event: null }, progress: [], payments: [],
|
||||
transportClosed: false, paymentClosed: false, fulfilled: false, released: false,
|
||||
};
|
||||
}
|
||||
|
||||
void test('whole shipment atoms preserve exact printed sectors and reject invalid ones', () => {
|
||||
assert.deepEqual(parseTurnActionLeaf({ kind: 'ship', territory: 'wind_pass', minimum: 3, sector: 15 }),
|
||||
{ kind: 'ship', territory: 'wind_pass', minimum: 3, sector: 15 });
|
||||
for (const input of [
|
||||
{ kind: 'ship', territory: 'wind_pass', minimum: 3, sector: 4 },
|
||||
{ kind: 'ship', territory: 'wind_pass', minimum: 3, sector: 15.5 },
|
||||
{ kind: 'move', territory: 'wind_pass', minimum: 3, sector: 15 },
|
||||
{ kind: 'ship', territory: 'wind_pass', minimum: 3, sector: null },
|
||||
]) assert.throws(() => parseTurnActionLeaf(input), TurnActionError);
|
||||
});
|
||||
|
||||
void test('one actual matching sector fulfills the signed Yes or rejects the signed No', () => {
|
||||
const exact = promise(ship(15));
|
||||
const [done] = recordTurnActionShipment([exact], 'p', 1, { territory: 'wind_pass', sector: 15, amount: 3 });
|
||||
assert.deepEqual(done.shipment.event, { territory: 'wind_pass', sector: 15, amount: 3 });
|
||||
assert.equal(done.fulfilled, true);
|
||||
assert.equal(turnActionValue(done), true);
|
||||
validateTurnActionPromises([done], ['p', 'a']);
|
||||
assert.throws(() => recordTurnActionShipment([promise(ship(15), false)], 'p', 1,
|
||||
{ territory: 'wind_pass', sector: 15, amount: 3 }), TurnActionError);
|
||||
const [other] = recordTurnActionShipment([promise(ship(15), false)], 'p', 1,
|
||||
{ territory: 'wind_pass', sector: 14, amount: 3 });
|
||||
assert.equal(other.fulfilled, true);
|
||||
assert.equal(turnActionValue(other), false);
|
||||
const [unknown] = recordTurnActionShipment([promise(ship(15), false)], 'p', 1,
|
||||
{ territory: 'wind_pass', amount: 3 });
|
||||
assert.equal(turnActionValue(unknown), false);
|
||||
});
|
||||
|
||||
void test('sector and territory-wide atom identity remain distinct across AND and OR', () => {
|
||||
const broad = ship(), exact14 = ship(14), exact15 = ship(15);
|
||||
const alternatives = promise(group('or', broad, exact14));
|
||||
validateTurnActionPromises([alternatives], ['p', 'a']);
|
||||
const branches = [...turnActionBranches(alternatives)];
|
||||
assert.equal(branches.length, 2);
|
||||
assert.ok(branches.some(row => row.some(leaf => leaf.kind === 'ship' && leaf.sector === undefined)));
|
||||
assert.ok(branches.some(row => row.some(leaf => leaf.kind === 'ship' && leaf.sector === 14)));
|
||||
assert.deepEqual([...turnActionBranches(promise(group('and', exact14, exact15)))], []);
|
||||
assert.equal([...turnActionBranches(promise(group('and', broad, exact14)))].length, 1);
|
||||
const [done] = recordTurnActionShipment([alternatives], 'p', 1, { territory: 'wind_pass', sector: 15, amount: 3 });
|
||||
assert.equal(done.fulfilled, true);
|
||||
});
|
||||
|
||||
void test('mixed exact and broad receipts retain one real event without changing legacy bytes', () => {
|
||||
const legacy = promise(ship());
|
||||
const [legacyDone] = recordTurnActionShipment([legacy], 'p', 1, { territory: 'wind_pass', sector: 15, amount: 3 });
|
||||
assert.deepEqual(legacyDone.shipment.event, { territory: 'wind_pass', amount: 3 });
|
||||
assert.equal(JSON.stringify(legacyDone.shipment.event), '{"territory":"wind_pass","amount":3}');
|
||||
validateTurnActionPromises([legacyDone], ['p', 'a']);
|
||||
const row = promise(group('or', ship(), ship(14)));
|
||||
const [done] = recordTurnActionShipment([row], 'p', 1, { territory: 'wind_pass', sector: 15, amount: 3 });
|
||||
assert.deepEqual(done.shipment.event, { territory: 'wind_pass', amount: 3, sector: 15 });
|
||||
validateTurnActionPromises([done], ['p', 'a']);
|
||||
assert.throws(() => recordTurnActionShipment([promise(ship(14))], 'p', 1,
|
||||
{ territory: 'wind_pass', sector: 19, amount: 3 }), TurnActionError);
|
||||
});
|
||||
Reference in new issue
Block a user