Allow fresh Advanced native No-Field placement
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 20:09:52 +11:00
1 parent 9ca68c9ff8
commit 26d45ecf25
13 files changed
+201 -21

No files matched your search

+31
View File
@@ -0,0 +1,31 @@
import assert from 'node:assert/strict';
import { viewGame, type Game } from '../game/engine';
import { richeseHomeworldMarkerChoice } from '../game/homeworld-shipment-options';
import type { NoFieldValue } from '../game/richese-no-field';
import {
createRicheseHomeworldShipmentFixture,
passRicheseHomeworldShipment,
richeseHomeworldShipmentPlayer,
} from './fixture-richese-homeworld-shipment';
export const owner = 'richese';
export const native = 'homeworld:richese';
export const pass = passRicheseHomeworldShipment;
export const player = richeseHomeworldShipmentPlayer;
/** Public Richese/Emperor setup and actual shipment opportunity. The shared
* fixture controls unopened card/spice ordering and obtains ordinary funding;
* it does not stage the marker, forces, or an in-flight decision. */
export function freshNativeMarkerFixture(advanced: boolean, value: NoFieldValue = 3) {
const f = createRicheseHomeworldShipmentFixture({advanced, companions:1, value});
const game = f.game;
const view = viewGame(game, owner);
const token = view.richeseNoField?.private?.tokens.find(row => row.value === value);
assert.ok(token, 'The original hidden marker must be available');
const quote = richeseHomeworldMarkerChoice(view, native, token.id, 0);
return {game, view, token:token.id, quote};
}
export function nativeMarkerQuote(game: Game, token: string) {
return richeseHomeworldMarkerChoice(viewGame(game, owner), native, token, 0);
}
+24 -3
View File
@@ -65,15 +65,17 @@ void test('Advanced deployed marker can select native Homeworld without exposing
assert.deepEqual(view, before);
});
void test('Basic return and fresh Advanced supply placement cannot target the native World', () => {
void test('Basic return remains unavailable while fresh Advanced supply can target the native World', () => {
const basic = fixture(false);
assert.doesNotMatch(destinations(markup(basic)), /value="homeworld:richese"/);
assert.equal(options.richeseHomeworldMarkerChoice(basic, 'homeworld:richese', 'five', 0, 'homeworld:atreides').action, null);
const fresh = fixture(true);
fresh.richeseNoField!.private = createRicheseNoField(['zero', 'three', 'five']);
fresh.richeseNoField!.canShip = true;
assert.doesNotMatch(destinations(markup(fresh)), /value="homeworld:richese"/);
assert.equal(options.richeseHomeworldMarkerChoice(fresh, 'homeworld:richese', 'five', 0).action, null);
assert.match(destinations(markup(fresh)), /value="homeworld:richese"/);
assert.deepEqual(options.richeseHomeworldMarkerChoice(fresh, 'homeworld:richese', 'five', 0).action,
{ type:'homeworldShip', event:'homeworld-event', destination:'homeworld:richese', sources:{},
noField:'five', noFieldEvent:'marker-event', allyPayment:0 });
});
type Element = ReactElement<{ children?: ReactNode; [key: string]: unknown }>;
@@ -119,4 +121,23 @@ void test('the marker control sends the selected native return through the norma
assert.deepEqual(JSON.parse(JSON.stringify(sent[0])), { type: 'homeworldShip', event: 'homeworld-event',
destination: 'homeworld:richese', sources: {}, noField: 'five', noFieldEvent: 'marker-event',
allyPayment: 0, noFieldFrom: 'homeworld:atreides' });
// A fresh placement must clear companions chosen for a foreign target:
// native reserves already occupy the destination and must not be charged.
view.richeseNoField!.private = createRicheseNoField(['zero', 'three', 'five']);
view.richeseNoField!.canShip = true;
states.length = 0;
sent.length = 0;
const companion = render().find(element => element.type === 'input' && element.props.type === 'number');
assert.ok(companion);
(companion.props.onChange as (event: {target: {value: string}}) => void)({target:{value:'2'}});
const freshSelect = render().find(element => element.type === 'select' && element.props['aria-label'] === 'No-Field destination Homeworld');
assert.ok(freshSelect);
(freshSelect.props.onChange as (event: {target: {value: string}}) => void)({target:{value:'homeworld:richese'}});
assert.ok(!render().some(element => element.type === 'input' && element.props.type === 'number'));
const freshButton = render().find(element => element.type === 'button');
assert.ok(freshButton && freshButton.props.disabled === false);
(freshButton.props.onClick as () => void)();
assert.deepEqual(JSON.parse(JSON.stringify(sent[0])), { type:'homeworldShip', event:'homeworld-event',
destination:'homeworld:richese', sources:{}, noField:'zero', noFieldEvent:'marker-event', allyPayment:0 });
});
+9 -2
View File
@@ -54,7 +54,7 @@ for (const tokenId of ['zero', 'three', 'five']) {
});
}
void test('Basic keeps foreign-only marker endpoints and fresh native placement remains unavailable', () => {
void test('Basic keeps foreign-only endpoints while Advanced permits a fresh native marker', () => {
const f = fixture(), basic = { ...f.context, advanced: false };
const intent = { player: 'r', tokenId: 'zero', source: foreign, destination: native };
assert.throws(() => quoteRicheseHomeworldMarkerTransfer(basic, f.custody, intent, f.noField), HomeworldCustodyError);
@@ -62,8 +62,15 @@ void test('Basic keeps foreign-only marker endpoints and fresh native placement
{ tokenId: 'zero', controller: 'r', location: { homeworld: native } });
assert.throws(() => quoteRicheseHomeworldMarkerTransfer(basic, f.custody,
{ player: 'r', tokenId: 'zero', source: native, destination: other }, nativeMarker), HomeworldCustodyError);
const supply = createRicheseNoField(['zero', 'three', 'five']);
assert.throws(() => quoteRicheseHomeworldMarkerShipment(basic, f.custody,
{ player: 'r', tokenId: 'three', destination: native }, supply), HomeworldCustodyError);
const placed = quoteRicheseHomeworldMarkerShipment(f.context, f.custody,
{ player: 'r', tokenId: 'three', destination: native }, supply);
assert.equal(placed.cost, 1);
assert.deepEqual(placed.noField.deployed?.location, {homeworld:native});
assert.throws(() => quoteRicheseHomeworldMarkerShipment(f.context, f.custody,
{ player: 'r', tokenId: 'three', destination: native }, createRicheseNoField(['zero', 'three', 'five'])), HomeworldCustodyError);
{ player: 'r', tokenId: 'three', destination: native, forces: {normal:1, elite:0} }, supply), HomeworldCustodyError);
assert.throws(() => quoteRicheseHomeworldMarkerTransfer(f.context, f.custody,
{ player: 'r', tokenId: 'zero', source: foreign, destination: foreign }, f.noField), HomeworldCustodyError);
const allied: HomeworldShipmentContext = { ...f.context, players: f.context.players.map(seat =>
@@ -48,7 +48,8 @@ void test('private marker quote emits the native shipment action without withdra
void test('marker quote respects eligibility, payment, privacy and current action windows', () => {
const view = fixture(), info = view.richeseNoField!;
assert.equal(options.richeseHomeworldMarkerChoice(view, 'homeworld:richese', 'zero').action, null);
assert.ok(options.richeseHomeworldMarkerChoice(view, 'homeworld:richese', 'zero').action);
assert.equal(options.richeseHomeworldMarkerChoice({...view, advanced:false}, 'homeworld:richese', 'zero').action, null);
assert.equal(options.richeseHomeworldMarkerChoice(view, 'homeworld:unknown', 'zero').action, null);
assert.equal(options.richeseHomeworldMarkerChoice(view, 'homeworld:atreides', 'missing').action, null);
info.private!.lastShipped = 'zero';
@@ -45,10 +45,10 @@ void test('rates and empty physical reserves do not alter the one-marker invoice
assert.equal(f.custody.visitors['homeworld:atreides'].r.normal, 2);
});
void test('own, allied, inactive and forged destinations or actors reject without mutation', () => {
void test('Basic own, allied, inactive and forged destinations or actors reject without mutation', () => {
for (const change of [
(f: ReturnType<typeof fixture>) => { f.intent.player = 'g'; },
(f: ReturnType<typeof fixture>) => { f.intent.destination = 'homeworld:richese'; },
(f: ReturnType<typeof fixture>) => { f.intent.destination = 'homeworld:richese'; f.context.advanced = false; },
(f: ReturnType<typeof fixture>) => { f.intent.destination = 'homeworld:unknown'; },
(f: ReturnType<typeof fixture>) => { f.context.players[0].ally = 'a'; f.context.players[1].ally = 'r'; },
(f: ReturnType<typeof fixture>) => { (f.intent as unknown as Record<string, unknown>).extra = 1; },
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame } from '../game/engine';
import { homeworldShipmentActions, richeseHomeworldMarkerChoice } from '../game/homeworld-shipment-options';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { freshNativeMarkerFixture, native, owner, pass, player } from './fixture-richese-native-marker';
for (const value of [0, 3, 5] as const) void test(
`Advanced fresh No-Field ${value} reaches native Richese without duplicating reserves`, () => {
const f = freshNativeMarkerFixture(true, value), before = structuredClone(f.game);
assert.equal(f.quote.blocked, null);
assert.equal(f.quote.amount, 1);
assert.equal(f.quote.cost, 1);
assert.ok(f.quote.action);
let game = applyAction(f.game, owner, f.quote.action);
assert.equal(game.response?.kind, 'richeseNoField');
assert.equal(player(game, owner).spice, player(before, owner).spice);
assert.equal(player(game, owner).reserves, player(before, owner).reserves);
game = pass(game);
assert.ok(player(game, owner).shipped);
assert.equal(player(game, owner).spice, player(before, owner).spice - 1);
assert.equal(player(game, owner).reserves, player(before, owner).reserves);
assert.deepEqual(game.homeworlds!.custody, before.homeworlds!.custody);
assert.deepEqual(player(game, owner).noField!.deployed!.location, {homeworld:native});
assert.equal(player(game, owner).noField!.lastShipped, f.token);
const publicMarker = viewGame(game, 'emperor').richeseNoField!;
assert.equal(publicMarker.private, null);
assert.equal(publicMarker.public.deployed?.effectiveForces, 1);
assert.equal(JSON.stringify(publicMarker.public).includes(f.token), false);
const own = player(game, owner);
game = pass(applyAction(game, owner, {type:'revealNoField', token:f.token, event:own.noFieldEvent}));
assert.equal(player(game, owner).noField!.deployed, null);
assert.equal(player(game, owner).reserves, player(before, owner).reserves);
assert.deepEqual(game.homeworlds!.custody, before.homeworlds!.custody);
assert.deepEqual(f.game, before, 'Applying the shipment leaves its source snapshot intact');
});
void test('Basic rejects fresh native placement while Advanced rejects same-source companions and stale events', () => {
const basic = freshNativeMarkerFixture(false);
const originalBasic = structuredClone(basic.game);
assert.equal(basic.quote.action, null);
assert.ok(basic.quote.blocked);
assert.deepEqual(basic.game, originalBasic);
const f = freshNativeMarkerFixture(true), before = structuredClone(f.game);
const mixed = richeseHomeworldMarkerChoice(f.view, native, f.token, 0, undefined, {normal:1, elite:0});
assert.equal(mixed.action, null, 'Native reserves cannot move from a World back to itself');
assert.ok(mixed.blocked);
assert.ok(f.quote.action);
assert.throws(() => applyAction(f.game, owner, {...f.quote.action!, noFieldEvent:'stale-marker'}));
assert.deepEqual(f.game, before);
});
void test('existing bot profiles retain a legal Homeworld action in the fresh native position', () => {
const f = freshNativeMarkerFixture(true);
for (const [index, difficulty] of DIFFICULTIES.entries()) {
const view = viewGame(f.game, owner);
view.players.find(row => row.id === owner)!.bot = difficulty;
const candidates = homeworldShipmentActions(view, index);
assert.ok(candidates.length, difficulty);
assert.doesNotThrow(() => applyAction(f.game, owner, candidates[0]), difficulty);
const nativeCandidate = candidates.find(action => action.type === 'homeworldShip' &&
action.destination === native && action.noField !== undefined);
assert.ok(nativeCandidate, `${difficulty} can select the native marker route`);
assert.doesNotThrow(() => applyAction(f.game, owner, nativeCandidate), difficulty);
const action = botActions(view)[0]; assert.ok(action, difficulty);
assert.doesNotThrow(() => applyAction(f.game, owner, action), difficulty);
}
});