Connect Sandmaster route collection and saved movement cancellation

Prototype optional one-spice collection from distinct entered territories along exact legal ground routes. Share route and collection checks between engine, controls and all four AI profiles. Preserve per-pile opt-out, multiple source sectors, physical force/spice custody and unchanged legacy moves. Apply the documented per-movement Hajr scope inference; special relocation, combined modules and ambiguous multiple-pile collection remain unfinished.

Bind Fremen declarations to their actual chosen route even when a shorter route reaches the endpoint. Preserve that commitment across saved responses, collect only on successful movement, and hide its internal proof from every player projection. Independent source, rules and persistence review completed.

Validation: 52 focused tests; typecheck, lint, 4504 offline tests, production build and 43 HTTP tests passed. SQLite restart, exact concurrent settlement, cancellation and legacy continuation passed. Browser exercised genuine assignment, route editing, collection opt-out/reselect, three moved forces, two collected spice, automatic notices and refresh with unchanged private cards. Two genuine four-profile Basic/Advanced samples completed in 196/89 actions with 6/3 JSON restorations and no rejected candidates; neither used Sandmaster collection, so targeted tests establish the effect.

Preserved all 495 opening rooms and original seat/recovery rows (529 rooms after QA/HTTP). SQLite integrity and foreign keys passed. The healthy server was reused; no restart, reset or automation.

Verified source tree: aec472f2dacdd49a13216a92bb4fb715af9d7ee028de6dd007cd496142eb06b1
This commit is contained in:
Quintine committed 2026-09-13 21:06:46 +10:00
1 parent 1380271bb6
commit bf8036dfc1
18 files changed
+1573 -25

No files matched your search

+181
View File
@@ -0,0 +1,181 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import {
applyAction,
createGame,
joinGame,
newPlayer,
viewGame,
type Action,
type Game,
} from '../game/engine';
import {
SandmasterMovement,
sandmasterMoveDraft,
} from '../components/sandmaster-movement';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { GRAPH, location } from '../game/board';
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
const source = location('false_wall_west', 16);
const destination = location('wind_pass', 16);
const spicePile = location('wind_pass', 15);
const move: Action = {
type: 'move',
from: source,
amount: 2,
elite: 0,
territory: 'wind_pass',
sector: 16,
};
function fixture(advanced = false): Game {
const game = createGame(
'SANDUI',
newPlayer('p', 'Mover', 'emperor'),
advanced,
);
joinGame(game, newPlayer('o', 'Observer', 'atreides'));
Object.assign(game, {
status: 'playing',
phase: 5,
turn: 2,
storm: 18,
order: ['p', 'o'],
active: 'p',
movementRemaining: ['p', 'o'],
spice: { [spicePile]: 3 },
});
for (const player of game.players) {
player.hand = [];
player.forces = {};
player.reserves = 20;
player.shipped = true;
}
game.players[0].forces[source] = 2;
game.players[0].reserves = 18;
game.leaderSkills = {
deck: LEADER_SKILL_CARDS.map((card) => card.id).filter(
(id) => id !== 'sandmaster',
),
offers: {},
assignments: [{ skill: 'sandmaster', leader: 'emperor-0', owner: 'p' }],
};
return game;
}
function markup(game: Game, viewer = 'p', busy = false) {
return renderToStaticMarkup(
createElement(SandmasterMovement, {
game: viewGame(game, viewer),
move,
act() {},
busy,
}),
);
}
void test('the owner sees exact default routes, per-pile opt-out and explicit route editing controls', () => {
const game = fixture();
const html = markup(game);
assert.match(html, /Sandmaster movement/);
assert.match(
html,
/False Wall West, sector 16 → Wind Pass, sector 16 · 1 \/ 1 territories/,
);
assert.match(
html,
/Collect 1 spice in Wind Pass, sector 15 \(3 on the pile\)/,
);
assert.match(html, /type="checkbox" checked=""/);
assert.match(html, /Remove last route step/);
assert.match(html, /Reset route to source/);
assert.match(html, /Use shortest route/);
assert.match(html, />Move with Sandmaster<\/button>/);
assert.match(html, /normal Move forces button to decline Sandmaster/);
assert.equal(markup(game, 'o'), '');
assert.ok((markup(game, 'p', true).match(/disabled=""/g) ?? []).length >= 4);
const combined = fixture();
combined.expansions = ['ix'];
assert.equal(markup(combined), '');
});
void test('drafts preserve the ordinary move, accept a connected alternate and allow collecting or declining', () => {
const game = fixture();
const view = viewGame(game, 'p');
const standard = sandmasterMoveDraft(view, move);
assert.equal(standard.blocked, null);
assert.deepEqual(standard.choice, {
routes: { [source]: [source, destination] },
collect: [spicePile],
});
assert.deepEqual(standard.action, {
...move,
sandmaster: standard.choice,
});
const collected = applyAction(structuredClone(game), 'p', standard.action!);
assert.equal(collected.players[0].spice, game.players[0].spice + 1);
assert.equal(collected.spice[spicePile], 2);
assert.ok(GRAPH[source].includes(location('wind_pass', 17)));
assert.ok(GRAPH[location('wind_pass', 17)].includes(destination));
const alternate = sandmasterMoveDraft(view, move, {
[source]: [source, location('wind_pass', 17), destination],
});
assert.equal(alternate.blocked, null);
assert.deepEqual(alternate.choice?.collect, [spicePile]);
const declined = sandmasterMoveDraft(view, move, undefined, [spicePile]);
assert.deepEqual(declined.choice?.collect, []);
const noCollection = applyAction(
structuredClone(game),
'p',
declined.action!,
);
assert.equal(noCollection.players[0].spice, game.players[0].spice);
assert.equal(noCollection.spice[spicePile], 3);
assert.match(
sandmasterMoveDraft(view, move, {
[source]: [source, location('arrakeen', 10), destination],
}).blocked!,
/connected route/,
);
});
void test('all four bot profiles add legal collection only to ordinary single-source candidates', () => {
for (const difficulty of DIFFICULTIES) {
const game = fixture();
const view = viewGame(game, 'p');
view.players.find((player) => player.id === 'p')!.bot = difficulty;
const candidates = botActions(view).filter(
(action) => action.type === 'move' && action.sandmaster,
);
assert.ok(candidates.length, `${difficulty} omitted Sandmaster collection`);
assert.ok(
candidates.every(
(action) =>
typeof action.from === 'string' &&
action.planetologist === undefined &&
action.movementCard === undefined &&
action.ornithopterEvent === undefined,
),
);
assert.doesNotThrow(
() => applyAction(game, 'p', candidates[0]),
`${difficulty} emitted an illegal Sandmaster move`,
);
const combined = viewGame(fixture(), 'p');
combined.players.find((player) => player.id === 'p')!.bot = difficulty;
combined.expansions = ['ix'];
assert.ok(
botActions(combined).every((action) => !action.sandmaster),
`${difficulty} emitted Sandmaster in a gated module combination`,
);
}
});
+61
View File
@@ -0,0 +1,61 @@
import assert from 'node:assert/strict';
import type { FactionId } from '../game/catalog';
import type { Action, Game } from '../game/engine';
import { viewGame } from '../game/engine';
import {
GRAPH,
mobileRouteDistance,
splitLocation,
territory,
} from '../game/board';
import {
sandmasterDefaultChoice,
sandmasterEnteredTerritories,
sandmasterShortestRoute,
} from '../game/sandmaster-movement';
import { smugglerShipmentGame } from './smuggler-shipment-fixture';
export function sandmasterMovementGame(
faction: FactionId = 'emperor',
advanced = false,
): Game {
const g = smugglerShipmentGame(faction, advanced, 'sandmaster');
g.players[0].shipped = true;
g.players[0].forces = { 'red_chasm:7': 3 };
g.players[0].reserves = 17;
if (faction !== 'fremen') {
g.players[0].forces['arrakeen:10'] = 1;
g.players[0].reserves--;
}
g.spice = {};
return g;
}
export function sandmasterMove(g: Game, steps = 2): Action {
const from = 'red_chasm:7';
const route = Object.keys(GRAPH)
.map((to) => sandmasterShortestRoute(g, 'p', from, to, steps))
.find(
(route) =>
route &&
mobileRouteDistance(route) === steps &&
sandmasterEnteredTerritories({ [from]: route }).length === steps &&
sandmasterEnteredTerritories({ [from]: route }).every(
(id) => territory(id).type === 'sand',
),
);
assert.ok(route);
for (const id of sandmasterEnteredTerritories({ [from]: route })) {
const sector = territory(id).sectors.find((s) => s !== g.storm)!;
g.spice[`${id}:${sector}`] = 4;
}
const target = splitLocation(route.at(-1)!);
const action: Action = {
type: 'move',
from,
amount: 3,
territory: target.territory,
sector: target.sector,
};
action.sandmaster = sandmasterDefaultChoice(viewGame(g, 'p'), 'p', action);
return action;
}
+241
View File
@@ -0,0 +1,241 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { DatabaseSync } from 'node:sqlite';
import type { Action, Game } from '../game/engine';
import type { RoomsClock } from '../db/rooms';
import { gameDistance, location, mobileRouteDistance } from '../game/board';
import type { SandmasterChoice } from '../game/sandmaster-movement';
import { unitStore } from './fixture-nexus-room-store';
import {
sandmasterMove,
sandmasterMovementGame,
} from './sandmaster-movement-fixture';
import { placeFixtureHand } from './fixture-hand';
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
async function fixture(detour = false) {
const store = unitStore();
const sqlite: DatabaseSync = store.sqlite;
const created = await store.rooms.createRoom(
'Fremen Sandmaster',
'fremen',
false,
[],
);
const joined = await store.rooms.joinRoom(
created.view.code,
'Guild responder',
'guild',
);
const code = created.view.code;
const tokens = [created.token!, joined.token!];
const original = await Promise.all(
tokens.map((token) => store.rooms.authenticate(code, token)),
);
for (const [index, seat] of original.entries())
sqlite
.prepare('UPDATE seats SET player_id=? WHERE room_code=? AND player_id=?')
.run(index === 0 ? 'p' : 'h', code, seat.playerId);
const seats = await Promise.all(
tokens.map((token) => store.restart().authenticate(code, token)),
);
const state = sandmasterMovementGame('fremen');
state.code = code;
state.version = (await store.rooms.readRoom(code)).version;
const karama = [
...state.deck,
...state.discard,
...state.players.flatMap((player) => player.hand),
].find((card) => card.effect === 'karama')!;
assert.ok(karama);
placeFixtureHand(state, 1, [karama]);
const action = sandmasterMove(state);
if (detour) {
const from = 'red_chasm:7';
const route = [from, 'pasty_mesa:6', 'pasty_mesa:5', 'south_mesa:4'];
state.spice = { 'pasty_mesa:6': 4, 'south_mesa:4': 4 };
Object.assign(action, {
territory: 'south_mesa',
sector: 4,
sandmaster: {
routes: { [from]: route },
collect: ['pasty_mesa:6', 'south_mesa:4'],
} satisfies SandmasterChoice,
});
assert.equal(mobileRouteDistance(route), 2);
assert.equal(
gameDistance(state, from, location('south_mesa', 4), (key) =>
key.endsWith(`:${state.storm}`),
),
1,
'The response must be bound to the chosen two-territory route, not only the endpoint distance.',
);
}
sqlite
.prepare('UPDATE rooms SET state=?,version=? WHERE code=?')
.run(JSON.stringify(state), state.version, code);
store.writes.length = 0;
return { ...store, sqlite, code, seats, state, action, karama };
}
function spiceTotal(g: Game) {
return (
Object.values(g.spice).reduce((sum, spice) => sum + spice, 0) +
g.players.reduce((sum, player) => sum + player.spice, 0)
);
}
function barrier() {
let release!: () => void;
const ready = new Promise<void>((resolve) => {
release = resolve;
});
let arrivals = 0;
return async () => {
if (++arrivals === 2) release();
await ready;
};
}
void test('SQLite restart keeps a Fremen Sandmaster route pending and concurrent allowance collects each pile once', async () => {
const f = await fixture(true);
try {
await f.rooms.act(f.code, f.seats[0], f.state.version, f.action, clock);
const pending = await f.restart().readRoom(f.code);
const saved = pending.pendingFremenMove?.order.sandmaster;
assert.equal(pending.response?.kind, 'fremenMovement');
assert.ok(saved);
assert.deepEqual(pending.players[0].forces, f.state.players[0].forces);
assert.equal(pending.players[0].spice, f.state.players[0].spice);
assert.deepEqual(pending.spice, f.state.spice);
assert.equal(spiceTotal(pending), spiceTotal(f.state));
for (const seat of f.seats) {
const view = await f.restart().readSeatView(f.code, seat);
assert.equal(view.response?.kind, 'fremenMovement');
assert.equal(view.response?.sandmasterProof, undefined);
assert.equal(
JSON.stringify(view.response).includes('sandmasterProof'),
false,
);
assert.equal('pendingFremenMove' in view, false);
const other = view.players.find((player) => player.id !== seat.playerId)!;
assert.equal('spice' in other, false);
assert.equal('hand' in other, false);
}
f.writes.length = 0;
f.hooks.beforeWrite = barrier();
const allow: Action = { type: 'passResponse' };
const attempts = await Promise.allSettled([
f.rooms.act(f.code, f.seats[1], pending.version, allow, clock),
f.restart().act(f.code, f.seats[1], pending.version, allow, clock),
]);
delete f.hooks.beforeWrite;
assert.equal(
attempts.filter((attempt) => attempt.status === 'fulfilled').length,
1,
);
assert.equal(
attempts.filter((attempt) => attempt.status === 'rejected').length,
1,
);
assert.deepEqual(
f.writes.map((write) => write.changes).sort((a, b) => a - b),
[0, 1],
);
const done = await f.restart().readRoom(f.code);
assert.equal(done.version, pending.version + 1);
assert.equal(done.pendingFremenMove, null);
assert.equal(done.response, null);
assert.equal(done.players[0].moved, 1);
assert.equal(
done.players[0].spice,
f.state.players[0].spice + saved.collect.length,
);
for (const pile of saved.piles)
assert.equal(done.spice[pile.key] ?? 0, pile.before - 1);
assert.equal(
done.log.filter(
(entry) => entry.automatic?.name === 'Sandmaster collection',
).length,
saved.collect.length,
);
assert.equal(spiceTotal(done), spiceTotal(f.state));
} finally {
f.sqlite.close();
}
});
void test('a restored Karama cancellation releases the Fremen Sandmaster route without moving forces or spice', async () => {
const f = await fixture();
try {
await f.rooms.act(f.code, f.seats[0], f.state.version, f.action, clock);
const pending = await f.restart().readRoom(f.code);
await f
.restart()
.act(
f.code,
f.seats[1],
pending.version,
{ type: 'card', mode: 'cancel', card: f.karama.id },
clock,
);
const canceled = await f.restart().readRoom(f.code);
assert.equal(canceled.pendingFremenMove, null);
assert.equal(canceled.response, null);
assert.equal(canceled.players[0].moved, 0);
assert.deepEqual(canceled.players[0].forces, f.state.players[0].forces);
assert.equal(canceled.players[0].spice, f.state.players[0].spice);
assert.deepEqual(canceled.spice, f.state.spice);
assert.deepEqual(canceled.players[0].fremenMovementBlocked, {
turn: f.state.turn,
move: 0,
});
assert.equal(
canceled.log.some(
(entry) => entry.automatic?.name === 'Sandmaster collection',
),
false,
);
assert.equal(spiceTotal(canceled), spiceTotal(f.state));
} finally {
f.sqlite.close();
}
});
void test('a restored movement without a Sandmaster selection remains an ordinary movement', async () => {
const f = await fixture();
try {
const { sandmaster: _unused, ...ordinary } = f.action;
await f.rooms.act(f.code, f.seats[0], f.state.version, ordinary, clock);
const pending = await f.restart().readRoom(f.code);
assert.equal(pending.response?.kind, 'fremenMovement');
assert.equal(pending.pendingFremenMove?.order.sandmaster, undefined);
await f
.restart()
.act(
f.code,
f.seats[1],
pending.version,
{ type: 'passResponse' },
clock,
);
const done = await f.restart().readRoom(f.code);
assert.equal(done.players[0].moved, 1);
assert.equal(done.players[0].spice, f.state.players[0].spice);
assert.deepEqual(done.spice, f.state.spice);
assert.equal(
done.log.some(
(entry) => entry.automatic?.name === 'Sandmaster collection',
),
false,
);
assert.equal(spiceTotal(done), spiceTotal(f.state));
} finally {
f.sqlite.close();
}
});
+290
View File
@@ -0,0 +1,290 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
applyAction,
normalizeAutomaticGame,
viewGame,
type Action,
type Game,
} from '../game/engine';
import { botActions } from '../game/bots';
import {
sandmasterDefaultChoice,
type SandmasterChoice,
} from '../game/sandmaster-movement';
import { splitLocation, territory } from '../game/board';
import {
sandmasterMove,
sandmasterMovementGame,
} from './sandmaster-movement-fixture';
import { createTechTokens } from '../game/tech-tokens';
import { placeFixtureHand } from './fixture-hand';
const restored = (g: Game): Game => JSON.parse(JSON.stringify(g));
function unchanged(
g: Game,
action: Action,
error = /Sandmaster|route|collection|source|spice/i,
) {
const before = structuredClone(g);
assert.throws(() => applyAction(g, 'p', action), error);
assert.deepEqual(g, before);
}
function conserved(g: Game, before: Game) {
for (const p of g.players)
assert.equal(
p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
20,
);
const sum = (game: Game) =>
Object.values(game.spice).reduce((a, b) => a + b, 0) +
game.players.reduce((a, p) => a + p.spice, 0);
assert.equal(sum(g), sum(before));
}
void test('Sandmaster collects once in every selected traversed territory and preserves JSON continuation', () => {
for (const advanced of [false, true]) {
const g = sandmasterMovementGame('emperor', advanced),
action = sandmasterMove(g, 3);
const choice = action.sandmaster as SandmasterChoice;
assert.equal(choice.collect.length, 3);
g.spice['red_chasm:7'] = 6;
const next = applyAction(restored(g), 'p', action);
assert.equal(next.players[0].spice, g.players[0].spice + 3);
assert.equal(next.spice['red_chasm:7'], 6);
assert.equal(next.players[0].moved, 1);
assert.equal(
next.log.filter((l) => l.automatic?.name === 'Sandmaster collection')
.length,
3,
);
assert.deepEqual(
viewGame(restored(next), 'p').players[0].hand,
viewGame(next, 'p').players[0].hand,
);
assert.equal(
viewGame(next, 'h').players.find((p) => p.id === 'p')!.spice,
undefined,
);
conserved(next, g);
}
});
void test('Sandmaster can decline each territory or the entire effect; legacy movement collects nothing', () => {
const g = sandmasterMovementGame(),
action = sandmasterMove(g);
const choice = action.sandmaster as SandmasterChoice;
for (const collect of [[], choice.collect.slice(0, 1)]) {
const next = applyAction(g, 'p', {
...action,
sandmaster: { ...choice, collect },
});
assert.equal(next.players[0].spice, g.players[0].spice + collect.length);
conserved(next, g);
}
const { sandmaster: _unused, ...legacy } = action;
assert.equal(
applyAction(g, 'p', legacy).players[0].spice,
g.players[0].spice,
);
});
void test('Sandmaster rejects disconnected, overlong, storm, duplicate and ambiguous collection choices without mutation', () => {
const g = sandmasterMovementGame(),
action = sandmasterMove(g),
choice = action.sandmaster as SandmasterChoice;
const from = 'red_chasm:7',
route = choice.routes[from];
unchanged(g, { ...action, sandmaster: { ...choice, routes: {} } });
unchanged(g, {
...action,
sandmaster: {
...choice,
routes: { [from]: [from, 'polar_sink:0', route.at(-1)] },
},
});
unchanged(g, {
...action,
sandmaster: { ...choice, collect: [choice.collect[0], choice.collect[0]] },
});
unchanged(g, {
...action,
sandmaster: { ...choice, collect: ['red_chasm:7'] },
});
unchanged(g, { ...action, sandmaster: null });
const blocked = restored(g);
blocked.storm = splitLocation(route.at(-1)!).sector;
unchanged(blocked, action, /storm|blocked|Sandmaster/i);
const tooFar = restored(g);
delete tooFar.players[0].forces['arrakeen:10'];
tooFar.players[0].reserves++;
unchanged(tooFar, action);
const id = splitLocation(choice.collect[0]).territory;
const second = territory(id).sectors.find(
(s) => `${id}:${s}` !== choice.collect[0],
);
if (second !== undefined) {
const ambiguous = restored(g);
ambiguous.spice[`${id}:${second}`] = 2;
unchanged(ambiguous, action);
const decline = {
...choice,
collect: choice.collect.filter((key) => key !== choice.collect[0]),
};
assert.doesNotThrow(() =>
applyAction(ambiguous, 'p', { ...action, sandmaster: decline }),
);
}
});
void test('Fremen Sandmaster waits for movement permission and cancellation releases untouched spice', () => {
const g = sandmasterMovementGame('fremen'),
action = sandmasterMove(g);
const karama = [...g.deck, ...g.players.flatMap((p) => p.hand)].find(
(c) => c.effect === 'karama',
)!;
placeFixtureHand(g, 1, [karama]);
const pending = applyAction(g, 'p', action);
assert.equal(pending.response?.kind, 'fremenMovement');
assert.equal(pending.players[0].spice, g.players[0].spice);
assert.deepEqual(pending.spice, g.spice);
const allowed = applyAction(restored(pending), 'h', { type: 'passResponse' });
assert.equal(allowed.players[0].spice, g.players[0].spice + 2);
conserved(allowed, g);
const canceled = applyAction(restored(pending), 'h', {
type: 'card',
mode: 'cancel',
card: karama.id,
});
assert.equal(canceled.players[0].spice, g.players[0].spice);
assert.deepEqual(canceled.spice, g.spice);
assert.equal(canceled.players[0].moved, 0);
for (const mutate of [
(saved: Game) => {
delete saved.pendingFremenMove!.order.sandmaster;
},
(saved: Game) => {
saved.response = null;
},
(saved: Game) => {
saved.pendingFremenMove!.order.sandmaster!.piles[0].before++;
},
(saved: Game) => {
saved.spice[(action.sandmaster as SandmasterChoice).collect[0]]++;
},
]) {
const saved = restored(pending);
mutate(saved);
assert.throws(() => viewGame(saved, 'p'), /Sandmaster/i);
assert.throws(() => normalizeAutomaticGame(saved), /Sandmaster/i);
}
});
void test('All four AI profiles can submit legal funded Sandmaster movement with only public route information', () => {
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const g = sandmasterMovementGame();
sandmasterMove(g);
g.players[0].autopilot = difficulty;
const view = viewGame(g, 'p');
const action = botActions(view).find(
(a) => a.type === 'move' && a.sandmaster,
);
assert.ok(action, difficulty);
const next = applyAction(g, 'p', action);
assert.ok(next.players[0].spice > g.players[0].spice);
conserved(next, g);
}
});
void test('Dead, captive and combined-module Sandmaster cannot grant normal collection', () => {
const g = sandmasterMovementGame(),
action = sandmasterMove(g);
const skill = g.leaderSkills!.assignments.find((a) => a.owner === 'p')!;
const captive = restored(g);
captive.players[0].leaders.find((l) => l.id === skill.leader)!.capturedBy =
'h';
unchanged(captive, action);
const combined = restored(g);
combined.techTokens = createTechTokens(combined.players);
unchanged(combined, action);
assert.equal(
sandmasterDefaultChoice(viewGame(combined, 'p'), 'p', action),
null,
);
});
void test('Sandmaster combines source sectors without duplicate collection and sector-only repositioning earns nothing', () => {
const g = sandmasterMovementGame();
g.players[0].forces = {
'pasty_mesa:6': 2,
'pasty_mesa:7': 1,
'arrakeen:10': 1,
};
g.spice = { 'red_chasm:7': 4, 'pasty_mesa:8': 5 };
const action: Action = {
type: 'move',
forces: { 'pasty_mesa:6': 2, 'pasty_mesa:7': 1 },
territory: 'red_chasm',
sector: 7,
};
const choice = sandmasterDefaultChoice(viewGame(g, 'p'), 'p', action)!;
assert.equal(Object.keys(choice.routes).length, 2);
assert.deepEqual(choice.collect, ['red_chasm:7']);
const next = applyAction(g, 'p', { ...action, sandmaster: choice });
assert.equal(next.players[0].spice, g.players[0].spice + 1);
assert.equal(next.players[0].forces['red_chasm:7'], 3);
assert.equal(next.spice['pasty_mesa:8'], 5);
conserved(next, g);
const reposition: Action = {
type: 'move',
from: 'pasty_mesa:6',
amount: 2,
territory: 'pasty_mesa',
sector: 7,
};
const sectorChoice = sandmasterDefaultChoice(
viewGame(g, 'p'),
'p',
reposition,
)!;
assert.deepEqual(sectorChoice.collect, []);
assert.equal(
applyAction(g, 'p', { ...reposition, sandmaster: sectorChoice }).players[0]
.spice,
g.players[0].spice,
);
});
void test('A separate Hajr movement can collect again and only a later re-entry qualifies its starting territory', () => {
const g = sandmasterMovementGame();
const hajr = g.deck.find((c) => c.effect === 'hajr')!;
assert.ok(hajr);
placeFixtureHand(g, 0, [hajr]);
g.spice = { 'pasty_mesa:8': 4, 'red_chasm:7': 5 };
const first: Action = {
type: 'move',
from: 'red_chasm:7',
amount: 3,
territory: 'pasty_mesa',
sector: 6,
};
let next = applyAction(g, 'p', {
...first,
sandmaster: sandmasterDefaultChoice(viewGame(g, 'p'), 'p', first),
});
assert.equal(next.spice['pasty_mesa:8'], 3);
next = applyAction(next, 'p', { type: 'card', card: hajr.id });
const second: Action = {
type: 'move',
from: 'pasty_mesa:6',
amount: 3,
territory: 'pasty_mesa',
sector: 7,
sandmaster: {
routes: {
'pasty_mesa:6': ['pasty_mesa:6', 'red_chasm:7', 'pasty_mesa:7'],
},
collect: ['pasty_mesa:8'],
},
};
next = applyAction(restored(next), 'p', second);
assert.equal(next.spice['pasty_mesa:8'], 2);
assert.equal(next.spice['red_chasm:7'], 5);
assert.equal(next.players[0].spice, g.players[0].spice + 2);
assert.equal(next.players[0].moved, 2);
conserved(next, g);
});
+3 -2
View File
@@ -17,6 +17,7 @@ import { botActions } from '../game/bots';
export function smugglerShipmentGame(
faction: FactionId = 'emperor',
advanced = false,
skill: 'smuggler' | 'sandmaster' = 'smuggler',
): Game {
let g = createGame('SMUGSHIP', newPlayer('p', 'Smuggler', faction), advanced);
joinGame(
@@ -24,7 +25,7 @@ export function smugglerShipmentGame(
newPlayer('h', 'Opponent', faction === 'guild' ? 'emperor' : 'guild'),
);
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
const index = LEADER_SKILL_CARDS.findIndex((c) => c.id === 'smuggler');
const index = LEADER_SKILL_CARDS.findIndex((c) => c.id === skill);
let cursor = LEADER_SKILL_CARDS.length - 1;
mock.method(globalThis.crypto, 'getRandomValues', (array: Uint32Array) => {
array[0] = cursor-- === index ? 0 : 0xffffffff;
@@ -46,7 +47,7 @@ export function smugglerShipmentGame(
{
type: 'leaderSkill',
event: skills.offer.event,
skill: p.id === 'p' ? 'smuggler' : skills.offer.cards[0],
skill: p.id === 'p' ? skill : skills.offer.cards[0],
leader: skills.eligibleLeaders[0].id,
},
]