Allow fresh Advanced native No-Field placement
Verify and publish checkpoint container / container (push) Canceled after 0s
Verify and publish checkpoint container / container (push) Canceled after 0s
This commit is contained in:
1 parent
9ca68c9ff8
commit
26d45ecf25
13 files changed
+201
-21
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user