Integrate Smuggler companion with owned Richese No-Field
Connect the GF9 FAQ combination: one free physical reserve force accompanies an owned concealed No-Field at its unchanged one-marker price. Preserve zero/three/five token values, actual supply at later reveal, optional human choice and all four legal AI paths. Genuine CHOAM & Richese Leader Skills development setup adds the two sourced battle cards without duplicating their Ix identities. Public mode and publication gates stay closed; allied and remaining combined routes are unfinished. Bind the private declaration across initial No-Field cancellation and later Advanced Guild interception, including saved interruptions. Validate before projection/actions/normalization, hide the internal proof, reject altered declarations immutably, and withdraw the companion once only on successful commitment. Independent rules/privacy/persistence review found no blocker. Preserve other audit setup gates despite expanded deck assembly. Repair the existing randomized recovery test with a genuine seeded Rihani offer and explicit human choice, replacing an inappropriate AI-policy call on a human seat. Initial Karama and later Guild stop retain their distinct existing opportunity outcomes. Validation: 72 focused regressions plus 10 deck tests; typecheck, lint, 4520 offline tests, production build and 43 HTTP tests passed. SQLite restart, concurrent allowance/cancellation and exact physical custody passed. Browser genuine assignment, private token selection, opt-in, one-spice shipment, refresh, five-force reveal and final refresh preserved six physical Arrakeen forces, 14 reserves, zero Tanks, four spice, Crysknife and Soo-Soo Sook. Genuine four-profile Basic/Advanced games completed in 77/116 actions with 3/4 JSON restores and no rejected candidates; Advanced exercised one companion shipment and later reveal. After the outage the existing server was healthy. Preserved all 529 opening rooms and original seat/recovery records (563 after QA/HTTP). SQLite integrity and foreign keys passed. No restart, database reset or automation. Verified source tree: c2aee4138bcb46a05814a340d62eadace3470247ca620844eb0375e78ea4d6c4
This commit is contained in:
1 parent
bf8036dfc1
commit
788453e07e
20 files changed
+1213
-51
No files matched your search
@@ -277,7 +277,7 @@ void test('the eight supported base and Ix faction setup paths conserve native a
|
||||
}
|
||||
});
|
||||
|
||||
void test('the four factions requiring unfinished expansion decks reject genuine Homeworld setup without replacing decks or lobby metadata', () => {
|
||||
void test('unprototyped CHOAM/Richese and Ecaz/Moritani combinations reject genuine Homeworld setup without replacing decks or lobby metadata', () => {
|
||||
for (const roster of [
|
||||
['choam', 'richese'],
|
||||
['ecaz', 'moritani'],
|
||||
|
||||
+12
-1
@@ -109,9 +109,20 @@ void test('the Ix expansion has fourteen distinct cards and combines into a fort
|
||||
baseDeck().some((c) => c.name === 'Basilia Weapon'),
|
||||
false,
|
||||
);
|
||||
for (const expansion of ['choam', 'ecaz', 'unknown'])
|
||||
for (const expansion of ['ecaz', 'unknown'])
|
||||
assert.throws(() => treacheryDeck([expansion]), /not implemented/);
|
||||
});
|
||||
void test('CHOAM & Richese adds or replaces the same two battle cards without duplicating physical custody', () => {
|
||||
for (const expansions of [['choam'], ['ix', 'choam'], ['choam', 'ix']]) {
|
||||
const cards = treacheryDeck(expansions);
|
||||
assert.equal(cards.length, expansions.includes('ix') ? 47 : 35);
|
||||
assert.equal(new Set(cards.map(c => c.id)).size, cards.length);
|
||||
assert.equal(cards.filter(c => c.kind === 'poisonTooth').length, 1);
|
||||
assert.equal(cards.filter(c => c.kind === 'artillery').length, 1);
|
||||
assert.equal(cards.filter(c => c.effect === 'karama').length, 2);
|
||||
assert.equal(cards.find(c => c.kind === 'poisonTooth')!.id, 'ix-poison-tooth');
|
||||
}
|
||||
});
|
||||
void test('deck factories return independent physical cards and retain the earlier Ix identifiers', () => {
|
||||
const a = treacheryDeck(['ix']),
|
||||
b = treacheryDeck(['ix']);
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import test from 'node:test';
|
||||
import { mock } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
|
||||
import {
|
||||
LEADER_SKILL_CARDS,
|
||||
type LeaderSkillId,
|
||||
} from '../game/leader-skill-cards';
|
||||
import {
|
||||
initializeLeaderSkillsGameForAudit,
|
||||
type Action,
|
||||
@@ -9,7 +13,6 @@ import {
|
||||
} from '../game/engine';
|
||||
import type { RoomsClock } from '../db/rooms';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
import { botActions } from '../game/bots';
|
||||
|
||||
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
|
||||
|
||||
@@ -42,7 +45,7 @@ function concurrentBarrier() {
|
||||
};
|
||||
}
|
||||
|
||||
async function fixture() {
|
||||
async function fixture(firstSkill?: LeaderSkillId) {
|
||||
const store = unitStore();
|
||||
const sqlite: DatabaseSync = store.sqlite;
|
||||
const created = await store.rooms.createRoom(
|
||||
@@ -69,7 +72,22 @@ async function fixture() {
|
||||
.act(code, auth, room.version, { type: 'ready' }, clock);
|
||||
}
|
||||
const ready = await store.restart().readRoom(code);
|
||||
const initialized = initializeLeaderSkillsGameForAudit(ready);
|
||||
const targetIndex = firstSkill
|
||||
? LEADER_SKILL_CARDS.findIndex((card) => card.id === firstSkill)
|
||||
: -1;
|
||||
let shuffleIndex = LEADER_SKILL_CARDS.length - 1;
|
||||
if (firstSkill)
|
||||
mock.method(globalThis.crypto, 'getRandomValues', (array: Uint32Array) => {
|
||||
array[0] = shuffleIndex-- === targetIndex ? 0 : 0xffffffff;
|
||||
return array;
|
||||
});
|
||||
let initialized: Game;
|
||||
try {
|
||||
initialized = initializeLeaderSkillsGameForAudit(ready);
|
||||
} finally {
|
||||
mock.restoreAll();
|
||||
}
|
||||
if (firstSkill) assert.equal(initialized.leaderSkills!.deck[0], firstSkill);
|
||||
sqlite
|
||||
.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
|
||||
.run(JSON.stringify(initialized), initialized.version, code);
|
||||
@@ -278,13 +296,17 @@ void test('real setup completion preserves prediction, starting hands, assignmen
|
||||
});
|
||||
|
||||
void test('battle death and Ghola revival preserve one private replacement skill through restart and concurrent selection', async () => {
|
||||
const f = await fixture();
|
||||
const f = await fixture('rihani-decipherer');
|
||||
try {
|
||||
let game = await act(f, 0, {
|
||||
type: 'predict',
|
||||
faction: 'atreides',
|
||||
turn: 4,
|
||||
});
|
||||
assert.equal(
|
||||
game.leaderSkills!.offers[f.auths[0].playerId].cards[0],
|
||||
'rihani-decipherer',
|
||||
);
|
||||
for (const [index, auth] of f.auths.entries()) {
|
||||
const seat = await f.restart().readSeatView(f.code, auth);
|
||||
const offer = seat.leaderSkills!.offer!;
|
||||
@@ -410,16 +432,20 @@ void test('battle death and Ghola revival preserve one private replacement skill
|
||||
1,
|
||||
);
|
||||
assertSkillCustody(game);
|
||||
// The randomly assigned winning skill can require casualty or Traitor choices
|
||||
// before card cleanup. Resolve those real choices before testing Ghola revival.
|
||||
for (let step = 0; step < 3 && ['rihani', 'sukRescue'].includes(game.decision?.kind ?? ''); step++) {
|
||||
const owner = f.auths.findIndex((auth) => auth.playerId === game.decision!.player);
|
||||
assert.ok(owner >= 0);
|
||||
const seat = await f.restart().readSeatView(f.code, f.auths[owner]);
|
||||
const [choice] = botActions(seat);
|
||||
assert.ok(choice, 'the winning skill must have a legal saved continuation');
|
||||
game = await act(f, owner, choice);
|
||||
}
|
||||
// The seeded winner has a real saved Rihani choice before card cleanup.
|
||||
assert.equal(game.decision?.kind, 'rihani');
|
||||
const owner = f.auths.findIndex(
|
||||
(auth) => auth.playerId === game.decision!.player,
|
||||
);
|
||||
assert.ok(owner >= 0);
|
||||
const seat = await f.restart().readSeatView(f.code, f.auths[owner]);
|
||||
assert.equal(seat.automaticContinuationPending, false);
|
||||
assert.equal(seat.decision?.kind, 'rihani');
|
||||
game = await act(f, owner, {
|
||||
type: 'decision',
|
||||
event: seat.decision.event,
|
||||
draw: false,
|
||||
});
|
||||
if (game.decision?.kind === 'battleCards')
|
||||
game = await act(
|
||||
f,
|
||||
|
||||
@@ -154,7 +154,7 @@ void test('the public collection and ten full card topics are searchable, linked
|
||||
);
|
||||
});
|
||||
|
||||
void test('base, Ix and Ecaz presentations and deck activation are unchanged', () => {
|
||||
void test('base, Ix and Ecaz presentations stay unchanged as the two CHOAM deck replacements become available', () => {
|
||||
for (const card of [...baseDeck(), ...ixDeck()]) {
|
||||
const presentation = cardPresentation(card);
|
||||
assert.equal(presentation.availability, undefined);
|
||||
@@ -173,7 +173,7 @@ void test('base, Ix and Ecaz presentations and deck activation are unchanged', (
|
||||
}
|
||||
assert.equal(treacheryDeck().length, 33);
|
||||
assert.equal(treacheryDeck(['ix']).length, 47);
|
||||
assert.throws(() => treacheryDeck(['choam']), /not implemented/);
|
||||
assert.equal(treacheryDeck(['choam']).length, 35);
|
||||
assert.throws(() => treacheryDeck(['richese']), /not implemented/);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import {
|
||||
RicheseNoFieldControls,
|
||||
SmugglerNoFieldChoice,
|
||||
} from '../components/richese-no-field';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { presenceAt } from '../game/force-presence';
|
||||
import {
|
||||
smugglerNoFieldAction,
|
||||
smugglerNoFieldGame,
|
||||
} from './smuggler-no-field-fixture';
|
||||
|
||||
function markup(game: Game, viewer = 'r', destination = 'arrakeen') {
|
||||
return renderToStaticMarkup(
|
||||
createElement(RicheseNoFieldControls, {
|
||||
game: viewGame(game, viewer),
|
||||
act() {},
|
||||
busy: false,
|
||||
destination,
|
||||
sector: destination === 'arrakeen' ? 10 : 9,
|
||||
allyPayment: '',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
void test('the private No-Field panel offers an unchecked free real-force companion without changing the marker price', () => {
|
||||
const html = markup(smugglerNoFieldGame());
|
||||
assert.match(html, /Private No-Field inventory/);
|
||||
for (const value of [0, 3, 5])
|
||||
assert.match(html, new RegExp(`No-Field ${value}`));
|
||||
assert.match(
|
||||
html,
|
||||
/Use Smuggler to add 1 real reserve force at no added cost/,
|
||||
);
|
||||
assert.doesNotMatch(html, /type="checkbox" checked=""/);
|
||||
assert.match(
|
||||
html,
|
||||
/No-Field token still uses the normal one-force shipment price/,
|
||||
);
|
||||
assert.match(html, /Ship concealed No-Field [035] · 1 spice/);
|
||||
assert.match(
|
||||
html,
|
||||
/Smuggler companion is a separate real force that leaves reserves immediately/,
|
||||
);
|
||||
|
||||
const optedIn = renderToStaticMarkup(
|
||||
createElement(SmugglerNoFieldChoice, {
|
||||
quote: { leader: 'richese-0', amount: 1 },
|
||||
use: true,
|
||||
busy: false,
|
||||
onChange() {},
|
||||
}),
|
||||
);
|
||||
assert.match(optedIn, /type="checkbox" checked=""/);
|
||||
assert.match(optedIn, /hidden forces remain in reserves until revelation/);
|
||||
assert.match(markup(smugglerNoFieldGame(true)), /Use Smuggler/);
|
||||
});
|
||||
|
||||
void test('the companion choice follows empty-territory and real-reserve gates and remains owner-private', () => {
|
||||
const occupied = smugglerNoFieldGame();
|
||||
occupied.players.find((player) => player.id === 'h')!.forces = {
|
||||
'arrakeen:9': 1,
|
||||
};
|
||||
occupied.players.find((player) => player.id === 'h')!.reserves = 19;
|
||||
assert.doesNotMatch(markup(occupied), /Use Smuggler/);
|
||||
|
||||
const emptyReserves = smugglerNoFieldGame();
|
||||
emptyReserves.players.find((player) => player.id === 'r')!.reserves = 0;
|
||||
assert.doesNotMatch(markup(emptyReserves), /Use Smuggler/);
|
||||
|
||||
const privateGame = smugglerNoFieldGame();
|
||||
assert.equal(markup(privateGame, 'h'), '');
|
||||
assert.equal(viewGame(privateGame, 'h').richeseNoField!.private, null);
|
||||
assert.doesNotMatch(markup(privateGame, 'h'), /No-Field [035]/);
|
||||
});
|
||||
|
||||
void test('all four bot profiles add the legal companion to owned No-Field shipments', () => {
|
||||
for (const advanced of [false, true])
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const game = smugglerNoFieldGame(advanced);
|
||||
const view = viewGame(game, 'r');
|
||||
view.players.find((player) => player.id === 'r')!.bot = difficulty;
|
||||
const noFieldActions = botActions(view).filter(
|
||||
(action) => action.type === 'ship' && action.noField,
|
||||
);
|
||||
const candidates = noFieldActions.filter(
|
||||
(action) => action.smuggler === true,
|
||||
);
|
||||
assert.ok(
|
||||
candidates.length,
|
||||
`${advanced ? 'Advanced' : 'Basic'} ${difficulty} omitted the companion`,
|
||||
);
|
||||
assert.ok(
|
||||
noFieldActions.every((action) => action.smuggler === true),
|
||||
`${advanced ? 'Advanced' : 'Basic'} ${difficulty} left a legal owned No-Field candidate unaugmented`,
|
||||
);
|
||||
const destination = String(candidates[0].territory);
|
||||
let settled = applyAction(game, 'r', candidates[0]);
|
||||
if (settled.decision?.kind === 'guildShipment')
|
||||
settled = applyAction(settled, settled.decision.player, {
|
||||
type: 'decision',
|
||||
allow: true,
|
||||
});
|
||||
assert.equal(settled.players[0].reserves, 19);
|
||||
assert.equal(presenceAt(settled.players[0], destination), 2);
|
||||
}
|
||||
});
|
||||
|
||||
void test('all profiles reveal a zero No-Field before moving when its real companion is alongside it', () => {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
let game = smugglerNoFieldGame();
|
||||
game = applyAction(game, 'r', smugglerNoFieldAction(game, 0, true));
|
||||
game.active = 'r';
|
||||
const view = viewGame(game, 'r');
|
||||
view.players.find((player) => player.id === 'r')!.bot = difficulty;
|
||||
const reveal = botActions(view)[0];
|
||||
assert.equal(reveal.type, 'revealNoField', difficulty);
|
||||
game = applyAction(game, 'r', reveal);
|
||||
assert.equal(game.players[0].noField!.deployed, null);
|
||||
assert.equal(game.players[0].reserves, 19);
|
||||
assert.equal(presenceAt(game.players[0], 'arrakeen'), 1);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mock } from 'node:test';
|
||||
import {
|
||||
applyAction,
|
||||
createGame,
|
||||
newPlayer,
|
||||
joinGame,
|
||||
initializeLeaderSkillsGameForAudit,
|
||||
viewGame,
|
||||
type Game,
|
||||
type Action,
|
||||
} from '../game/engine';
|
||||
import { LEADER_SKILL_CARDS } from '../game/leader-skill-cards';
|
||||
import { botActions } from '../game/bots';
|
||||
|
||||
/** Genuine Richese + Leader Skills setup, then an explicitly conserved shipment scenario. */
|
||||
export function smugglerNoFieldGame(advanced = false): Game {
|
||||
let g = createGame(
|
||||
'SMUGNOFL',
|
||||
newPlayer('r', 'Richese', 'richese'),
|
||||
advanced,
|
||||
['choam'],
|
||||
);
|
||||
joinGame(g, newPlayer('h', 'Harkonnen', 'harkonnen'));
|
||||
joinGame(g, newPlayer('g', 'Guild', 'guild'));
|
||||
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
||||
const index = LEADER_SKILL_CARDS.findIndex((c) => c.id === 'smuggler');
|
||||
let cursor = LEADER_SKILL_CARDS.length - 1;
|
||||
mock.method(globalThis.crypto, 'getRandomValues', (array: Uint32Array) => {
|
||||
array[0] = cursor-- === index ? 0 : 0xffffffff;
|
||||
return array;
|
||||
});
|
||||
try {
|
||||
g = initializeLeaderSkillsGameForAudit(g);
|
||||
} finally {
|
||||
mock.restoreAll();
|
||||
}
|
||||
for (let i = 0; g.status === 'setup' && i < 80; i++) {
|
||||
let accepted = false;
|
||||
for (const p of g.players) {
|
||||
const view = viewGame(g, p.id);
|
||||
const skills = view.leaderSkills!;
|
||||
const options: Action[] =
|
||||
g.setupStage === 'leaderSkills' && skills.offer
|
||||
? [
|
||||
{
|
||||
type: 'leaderSkill',
|
||||
event: skills.offer.event,
|
||||
skill: p.id === 'r' ? 'smuggler' : skills.offer.cards[0],
|
||||
leader: skills.eligibleLeaders[0].id,
|
||||
},
|
||||
]
|
||||
: g.setupStage === 'traitors' && p.traitorChoices.length
|
||||
? [{ type: 'traitor', leader: p.traitorChoices[0] }]
|
||||
: botActions(view);
|
||||
for (const a of options) {
|
||||
try {
|
||||
g = applyAction(g, p.id, a);
|
||||
accepted = true;
|
||||
break;
|
||||
} catch {
|
||||
/* Other seats may be waiting. */
|
||||
}
|
||||
}
|
||||
if (accepted) break;
|
||||
}
|
||||
assert.ok(accepted, `setup ${g.setupStage}`);
|
||||
}
|
||||
assert.equal(g.status, 'playing');
|
||||
assert.ok(g.players[0].noField);
|
||||
Object.assign(g, {
|
||||
phase: 5,
|
||||
turn: 2,
|
||||
storm: 18,
|
||||
active: 'r',
|
||||
order: ['r', 'h', 'g'],
|
||||
movementRemaining: ['r', 'h', 'g'],
|
||||
ready: [],
|
||||
response: null,
|
||||
decision: null,
|
||||
phaseOpening: null,
|
||||
});
|
||||
for (const p of g.players) {
|
||||
g.deck.push(...p.hand);
|
||||
Object.assign(p, {
|
||||
hand: [],
|
||||
forces: {},
|
||||
reserves: 20,
|
||||
tanks: 0,
|
||||
spice: 10,
|
||||
shipped: false,
|
||||
moved: 0,
|
||||
advisors: {},
|
||||
});
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
export function smugglerNoFieldAction(
|
||||
g: Game,
|
||||
value: 0 | 3 | 5 = 5,
|
||||
smuggler = true,
|
||||
): Action {
|
||||
const p = g.players.find((p) => p.id === 'r')!;
|
||||
return {
|
||||
type: 'ship',
|
||||
noField: p.noField!.tokens.find((t) => t.value === value)!.id,
|
||||
event: p.noFieldEvent,
|
||||
territory: 'arrakeen',
|
||||
sector: 10,
|
||||
smuggler,
|
||||
};
|
||||
}
|
||||
|
||||
export function conserveNoFieldForces(g: Game) {
|
||||
for (const p of g.players)
|
||||
assert.equal(
|
||||
p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
|
||||
20,
|
||||
p.id,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { DatabaseSync } from 'node:sqlite';
|
||||
import type { Action } from '../game/engine';
|
||||
import type { RoomsClock } from '../db/rooms';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
import { placeFixtureHand } from './fixture-hand';
|
||||
import {
|
||||
conserveNoFieldForces,
|
||||
smugglerNoFieldAction,
|
||||
smugglerNoFieldGame,
|
||||
} from './smuggler-no-field-fixture';
|
||||
|
||||
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
|
||||
|
||||
async function fixture(advanced = false, canceler: 'h' | 'g' | null = null) {
|
||||
const store = unitStore();
|
||||
const sqlite: DatabaseSync = store.sqlite;
|
||||
const created = await store.rooms.createRoom(
|
||||
'Richese Smuggler',
|
||||
'richese',
|
||||
advanced,
|
||||
['choam'],
|
||||
);
|
||||
const joined = await store.rooms.joinRoom(
|
||||
created.view.code,
|
||||
'Harkonnen',
|
||||
'harkonnen',
|
||||
);
|
||||
const guild = await store.rooms.joinRoom(created.view.code, 'Guild', 'guild');
|
||||
const code = created.view.code;
|
||||
const tokens = [created.token!, joined.token!, guild.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(['r', 'h', 'g'][index], code, seat.playerId);
|
||||
const seats = await Promise.all(
|
||||
tokens.map((token) => store.restart().authenticate(code, token)),
|
||||
);
|
||||
|
||||
const state = smugglerNoFieldGame(advanced);
|
||||
state.code = code;
|
||||
state.version = (await store.rooms.readRoom(code)).version;
|
||||
let karama = null;
|
||||
if (canceler) {
|
||||
karama = [
|
||||
...state.deck,
|
||||
...state.discard,
|
||||
...state.players.flatMap((player) => player.hand),
|
||||
].find((card) => card.effect === 'karama')!;
|
||||
assert.ok(karama);
|
||||
placeFixtureHand(state, canceler === 'h' ? 1 : 2, [karama]);
|
||||
}
|
||||
const action = smugglerNoFieldAction(state);
|
||||
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 barrier() {
|
||||
let release!: () => void;
|
||||
const ready = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
let arrivals = 0;
|
||||
return async () => {
|
||||
if (++arrivals === 2) release();
|
||||
await ready;
|
||||
};
|
||||
}
|
||||
|
||||
function committedOnce(
|
||||
results: PromiseSettledResult<unknown>[],
|
||||
writes: { changes: number }[],
|
||||
) {
|
||||
assert.equal(
|
||||
results.filter((result) => result.status === 'fulfilled').length,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
results.filter((result) => result.status === 'rejected').length,
|
||||
1,
|
||||
);
|
||||
assert.deepEqual(
|
||||
writes.map((write) => write.changes).sort((a, b) => a - b),
|
||||
[0, 1],
|
||||
);
|
||||
}
|
||||
|
||||
void test('restart preserves the concealed companion through the No-Field and Guild stages, then concurrent allowance commits it once', async () => {
|
||||
const f = await fixture(true, 'g');
|
||||
try {
|
||||
await f.rooms.act(f.code, f.seats[0], f.state.version, f.action, clock);
|
||||
const initial = await f.restart().readRoom(f.code);
|
||||
assert.equal(initial.response?.kind, 'richeseNoField');
|
||||
assert.deepEqual(initial.pendingShipment?.smugglerCompanion, {
|
||||
leader: initial.leaderSkills!.assignments.find(
|
||||
(assignment) => assignment.owner === 'r',
|
||||
)!.leader,
|
||||
amount: 1,
|
||||
});
|
||||
assert.equal(initial.pendingShipment?.amount, 1);
|
||||
assert.equal(initial.pendingShipment?.cost, 1);
|
||||
assert.equal(initial.players[0].reserves, 20);
|
||||
assert.deepEqual(initial.players[0].forces, {});
|
||||
assert.equal(initial.players[0].noField!.deployed, null);
|
||||
|
||||
for (const seat of f.seats) {
|
||||
const view = await f.restart().readSeatView(f.code, seat);
|
||||
assert.equal(JSON.stringify(view).includes('noFieldSkillProof'), false);
|
||||
assert.equal('pendingShipment' in view, false);
|
||||
if (seat.playerId !== 'r')
|
||||
assert.equal(
|
||||
JSON.stringify(view).includes(String(f.action.noField)),
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
f.writes.length = 0;
|
||||
f.hooks.beforeWrite = barrier();
|
||||
const passes = await Promise.allSettled([
|
||||
f.rooms.act(
|
||||
f.code,
|
||||
f.seats[2],
|
||||
initial.version,
|
||||
{ type: 'passResponse' },
|
||||
clock,
|
||||
),
|
||||
f
|
||||
.restart()
|
||||
.act(
|
||||
f.code,
|
||||
f.seats[2],
|
||||
initial.version,
|
||||
{ type: 'passResponse' },
|
||||
clock,
|
||||
),
|
||||
]);
|
||||
delete f.hooks.beforeWrite;
|
||||
committedOnce(passes, f.writes);
|
||||
|
||||
const guildDecision = await f.restart().readRoom(f.code);
|
||||
assert.equal(guildDecision.decision?.kind, 'guildShipment');
|
||||
assert.deepEqual(
|
||||
guildDecision.pendingShipment?.smugglerCompanion,
|
||||
initial.pendingShipment?.smugglerCompanion,
|
||||
);
|
||||
assert.equal(guildDecision.players[0].reserves, 20);
|
||||
assert.deepEqual(guildDecision.players[0].forces, {});
|
||||
for (const seat of f.seats) {
|
||||
const view = await f.restart().readSeatView(f.code, seat);
|
||||
assert.equal(
|
||||
JSON.stringify(view.decision).includes('noFieldSkillProof'),
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
f.writes.length = 0;
|
||||
f.hooks.beforeWrite = barrier();
|
||||
const allow: Action = { type: 'decision', allow: true };
|
||||
const allowances = await Promise.allSettled([
|
||||
f.rooms.act(f.code, f.seats[2], guildDecision.version, allow, clock),
|
||||
f.restart().act(f.code, f.seats[2], guildDecision.version, allow, clock),
|
||||
]);
|
||||
delete f.hooks.beforeWrite;
|
||||
committedOnce(allowances, f.writes);
|
||||
|
||||
const shipped = await f.restart().readRoom(f.code);
|
||||
assert.equal(shipped.pendingShipment, null);
|
||||
assert.equal(shipped.decision, null);
|
||||
assert.equal(shipped.players[0].reserves, 19);
|
||||
assert.equal(shipped.players[0].forces['arrakeen:10'], 1);
|
||||
assert.equal(shipped.players[0].spice, 9);
|
||||
assert.equal(shipped.players[2].spice, 11);
|
||||
assert.equal(
|
||||
shipped.log.filter(
|
||||
(entry) => entry.automatic?.name === 'No-Field shipment',
|
||||
).length,
|
||||
1,
|
||||
);
|
||||
conserveNoFieldForces(shipped);
|
||||
|
||||
await f.restart().act(
|
||||
f.code,
|
||||
f.seats[0],
|
||||
shipped.version,
|
||||
{
|
||||
type: 'revealNoField',
|
||||
event: shipped.players[0].noFieldEvent,
|
||||
token: shipped.players[0].noField!.deployed!.tokenId,
|
||||
},
|
||||
clock,
|
||||
);
|
||||
const revealed = await f.restart().readRoom(f.code);
|
||||
assert.equal(revealed.players[0].reserves, 14);
|
||||
assert.equal(revealed.players[0].forces['arrakeen:10'], 6);
|
||||
conserveNoFieldForces(revealed);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('concurrent restored No-Field cancellation leaves the marker and companion in reserve custody', async () => {
|
||||
const f = await fixture(false, 'h');
|
||||
try {
|
||||
await f.rooms.act(f.code, f.seats[0], f.state.version, f.action, clock);
|
||||
const pending = await f.restart().readRoom(f.code);
|
||||
assert.equal(pending.response?.kind, 'richeseNoField');
|
||||
const cancel: Action = {
|
||||
type: 'card',
|
||||
mode: 'cancel',
|
||||
card: f.karama!.id,
|
||||
};
|
||||
f.writes.length = 0;
|
||||
f.hooks.beforeWrite = barrier();
|
||||
const cancellations = await Promise.allSettled([
|
||||
f.rooms.act(f.code, f.seats[1], pending.version, cancel, clock),
|
||||
f.restart().act(f.code, f.seats[1], pending.version, cancel, clock),
|
||||
]);
|
||||
delete f.hooks.beforeWrite;
|
||||
committedOnce(cancellations, f.writes);
|
||||
|
||||
const canceled = await f.restart().readRoom(f.code);
|
||||
assert.equal(canceled.pendingShipment, null);
|
||||
assert.equal(canceled.response, null);
|
||||
assert.equal(canceled.players[0].reserves, 20);
|
||||
assert.deepEqual(canceled.players[0].forces, {});
|
||||
assert.equal(canceled.players[0].spice, 10);
|
||||
assert.equal(canceled.players[0].shipped, false);
|
||||
assert.equal(canceled.players[0].noField!.deployed, null);
|
||||
assert.equal(canceled.players[0].noField!.lastShipped, null);
|
||||
assert.equal(
|
||||
canceled.discard.filter((card) => card.id === f.karama!.id).length,
|
||||
1,
|
||||
);
|
||||
conserveNoFieldForces(canceled);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('restored Guild cancellation consumes the shipment opportunity without withdrawing the companion', async () => {
|
||||
const f = await fixture(true, 'g');
|
||||
try {
|
||||
await f.rooms.act(f.code, f.seats[0], f.state.version, f.action, clock);
|
||||
let pending = await f.restart().readRoom(f.code);
|
||||
assert.equal(pending.response?.kind, 'richeseNoField');
|
||||
await f
|
||||
.restart()
|
||||
.act(
|
||||
f.code,
|
||||
f.seats[2],
|
||||
pending.version,
|
||||
{ type: 'passResponse' },
|
||||
clock,
|
||||
);
|
||||
pending = await f.restart().readRoom(f.code);
|
||||
assert.equal(pending.decision?.kind, 'guildShipment');
|
||||
assert.equal(pending.players[0].reserves, 20);
|
||||
await f.restart().act(
|
||||
f.code,
|
||||
f.seats[2],
|
||||
pending.version,
|
||||
{
|
||||
type: 'card',
|
||||
mode: 'special',
|
||||
card: f.karama!.id,
|
||||
target: 'r',
|
||||
},
|
||||
clock,
|
||||
);
|
||||
const canceled = await f.restart().readRoom(f.code);
|
||||
assert.equal(canceled.pendingShipment, null);
|
||||
assert.equal(canceled.decision, null);
|
||||
assert.equal(canceled.players[0].reserves, 20);
|
||||
assert.deepEqual(canceled.players[0].forces, {});
|
||||
assert.equal(canceled.players[0].spice, 10);
|
||||
assert.equal(canceled.players[0].shipped, true);
|
||||
assert.equal(canceled.players[0].noField!.deployed, null);
|
||||
assert.equal(canceled.players[0].noField!.lastShipped, null);
|
||||
assert.equal(canceled.players[2].specialKaramaUsed, true);
|
||||
assert.equal(
|
||||
canceled.discard.filter((card) => card.id === f.karama!.id).length,
|
||||
1,
|
||||
);
|
||||
conserveNoFieldForces(canceled);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
|
||||
void test('a restored No-Field declaration without the Smuggler flag retains legacy token-only custody', async () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
const { smuggler: _unused, ...legacy } = f.action;
|
||||
await f.rooms.act(f.code, f.seats[0], f.state.version, legacy, clock);
|
||||
const shipped = await f.restart().readRoom(f.code);
|
||||
assert.equal(shipped.pendingShipment, null);
|
||||
assert.equal(shipped.players[0].reserves, 20);
|
||||
assert.deepEqual(shipped.players[0].forces, {});
|
||||
assert.equal(shipped.players[0].spice, 9);
|
||||
assert.ok(shipped.players[0].noField!.deployed);
|
||||
assert.equal(
|
||||
shipped.log.some((entry) => entry.text.includes('free Smuggler force')),
|
||||
false,
|
||||
);
|
||||
conserveNoFieldForces(shipped);
|
||||
} finally {
|
||||
f.sqlite.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,245 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
applyAction,
|
||||
viewGame,
|
||||
normalizeAutomaticGame,
|
||||
type Game,
|
||||
type Action,
|
||||
} from '../game/engine';
|
||||
import { quoteSmugglerNoField } from '../game/smuggler-no-field';
|
||||
import { presenceAt } from '../game/force-presence';
|
||||
import {
|
||||
smugglerNoFieldGame,
|
||||
smugglerNoFieldAction,
|
||||
conserveNoFieldForces,
|
||||
} from './smuggler-no-field-fixture';
|
||||
|
||||
function reject(g: Game, actor: string, action: Action) {
|
||||
const original = JSON.stringify(g);
|
||||
assert.throws(() => applyAction(g, actor, action));
|
||||
assert.equal(JSON.stringify(g), original);
|
||||
}
|
||||
function karama(g: Game, player = 'h') {
|
||||
const i = g.deck.findIndex((c) => c.effect === 'karama');
|
||||
assert.ok(i >= 0);
|
||||
const card = g.deck.splice(i, 1)[0];
|
||||
g.players.find((p) => p.id === player)!.hand.push(card);
|
||||
return card.id;
|
||||
}
|
||||
|
||||
void test('each hidden No-Field value ships one separate Smuggler force at the unchanged marker price', () => {
|
||||
for (const value of [0, 3, 5] as const) {
|
||||
const original = smugglerNoFieldGame();
|
||||
const action = smugglerNoFieldAction(original, value);
|
||||
const shipped = applyAction(original, 'r', action);
|
||||
assert.equal(shipped.players[0].spice, 9);
|
||||
assert.equal(shipped.players[2].spice, 11);
|
||||
assert.equal(shipped.players[0].reserves, 19);
|
||||
assert.equal(shipped.players[0].forces['arrakeen:10'], 1);
|
||||
assert.equal(presenceAt(shipped.players[0], 'arrakeen'), 2);
|
||||
assert.equal(shipped.players[0].noField!.deployed!.tokenId, action.noField);
|
||||
assert.equal(original.players[0].reserves, 20);
|
||||
conserveNoFieldForces(shipped);
|
||||
const other = JSON.stringify(viewGame(shipped, 'h'));
|
||||
assert.equal(other.includes(String(action.noField)), false);
|
||||
assert.equal(other.includes('noFieldSkillProof'), false);
|
||||
const restored = JSON.parse(JSON.stringify(shipped));
|
||||
const revealed = applyAction(restored, 'r', {
|
||||
type: 'revealNoField',
|
||||
event: restored.players[0].noFieldEvent,
|
||||
token: action.noField,
|
||||
});
|
||||
assert.equal(revealed.players[0].forces['arrakeen:10'], 1 + value);
|
||||
assert.equal(revealed.players[0].reserves, 19 - value);
|
||||
conserveNoFieldForces(revealed);
|
||||
reject(revealed, 'r', action);
|
||||
}
|
||||
});
|
||||
|
||||
void test('a scarce reserve pays the companion once; later No-Field reveal uses only the remaining supply', () => {
|
||||
for (const reserves of [1, 2]) {
|
||||
const g = smugglerNoFieldGame();
|
||||
g.players[0].reserves = reserves;
|
||||
g.players[0].tanks = 20 - reserves;
|
||||
let done = applyAction(g, 'r', smugglerNoFieldAction(g));
|
||||
done = applyAction(done, 'r', {
|
||||
type: 'revealNoField',
|
||||
event: done.players[0].noFieldEvent,
|
||||
token: done.players[0].noField!.deployed!.tokenId,
|
||||
});
|
||||
assert.equal(done.players[0].forces['arrakeen:10'], reserves);
|
||||
assert.equal(done.players[0].reserves, 0);
|
||||
conserveNoFieldForces(done);
|
||||
}
|
||||
const empty = smugglerNoFieldGame();
|
||||
empty.players[0].reserves = 0;
|
||||
empty.players[0].tanks = 20;
|
||||
reject(empty, 'r', smugglerNoFieldAction(empty));
|
||||
const plain = applyAction(empty, 'r', smugglerNoFieldAction(empty, 0, false));
|
||||
assert.equal(plain.players[0].reserves, 0);
|
||||
assert.equal(plain.players[0].spice, 9);
|
||||
assert.deepEqual(plain.players[0].forces, {});
|
||||
conserveNoFieldForces(plain);
|
||||
});
|
||||
|
||||
void test('optional decline and desert pricing preserve the full one-marker price', () => {
|
||||
for (const smuggler of [false, undefined]) {
|
||||
const g = smugglerNoFieldGame();
|
||||
const done = applyAction(g, 'r', { ...smugglerNoFieldAction(g), smuggler });
|
||||
assert.equal(done.players[0].reserves, 20);
|
||||
assert.equal(done.players[0].spice, 9);
|
||||
assert.deepEqual(done.players[0].forces, {});
|
||||
}
|
||||
const g = smugglerNoFieldGame();
|
||||
const done = applyAction(g, 'r', {
|
||||
...smugglerNoFieldAction(g),
|
||||
territory: 'wind_pass',
|
||||
sector: 14,
|
||||
});
|
||||
assert.equal(done.players[0].spice, 8);
|
||||
assert.equal(done.players[2].spice, 12);
|
||||
assert.equal(done.players[0].forces['wind_pass:14'], 1);
|
||||
conserveNoFieldForces(done);
|
||||
});
|
||||
|
||||
void test('Smuggler companion requires native living public custody and a wholly empty territory', () => {
|
||||
const g = smugglerNoFieldGame();
|
||||
assert.ok(quoteSmugglerNoField(g, 'r', 'arrakeen'));
|
||||
for (const owner of ['r', 'h']) {
|
||||
const occupied = smugglerNoFieldGame();
|
||||
const p = occupied.players.find((p) => p.id === owner)!;
|
||||
p.reserves--;
|
||||
p.forces['wind_pass:15'] = 1;
|
||||
assert.equal(quoteSmugglerNoField(occupied, 'r', 'wind_pass'), null);
|
||||
reject(occupied, 'r', {
|
||||
...smugglerNoFieldAction(occupied),
|
||||
territory: 'wind_pass',
|
||||
sector: 14,
|
||||
});
|
||||
}
|
||||
const hidden = viewGame(g, 'r');
|
||||
hidden.leaderSkills!.assignments.find((a) => a.owner === 'r')!.faceUp = false;
|
||||
assert.equal(quoteSmugglerNoField(hidden, 'r', 'arrakeen'), null);
|
||||
for (const absent of [
|
||||
{ dead: true },
|
||||
{ capturedBy: 'h' },
|
||||
{ gholaBy: 'h' },
|
||||
]) {
|
||||
const unavailable = smugglerNoFieldGame();
|
||||
const leader = unavailable.players[0].leaders.find(
|
||||
(l) =>
|
||||
l.id ===
|
||||
unavailable.leaderSkills!.assignments.find((a) => a.owner === 'r')!
|
||||
.leader,
|
||||
)!;
|
||||
Object.assign(leader, absent);
|
||||
assert.equal(quoteSmugglerNoField(unavailable, 'r', 'arrakeen'), null);
|
||||
}
|
||||
});
|
||||
|
||||
void test('initial Karama cancellation preserves the companion, spice, token history and ordinary shipment opportunity', () => {
|
||||
const g = smugglerNoFieldGame();
|
||||
const card = karama(g);
|
||||
const pending = applyAction(g, 'r', smugglerNoFieldAction(g));
|
||||
assert.equal(pending.response?.kind, 'richeseNoField');
|
||||
assert.equal(pending.pendingShipment?.amount, 1);
|
||||
assert.equal(pending.pendingShipment?.smugglerCompanion?.amount, 1);
|
||||
const canceled = applyAction(JSON.parse(JSON.stringify(pending)), 'h', {
|
||||
type: 'card',
|
||||
mode: 'cancel',
|
||||
card,
|
||||
});
|
||||
assert.equal(canceled.players[0].reserves, 20);
|
||||
assert.equal(canceled.players[0].spice, 10);
|
||||
assert.equal(canceled.players[0].noField!.lastShipped, null);
|
||||
assert.equal(canceled.players[0].shipped, false);
|
||||
const fallback = applyAction(canceled, 'r', {
|
||||
type: 'ship',
|
||||
amount: 1,
|
||||
territory: 'arrakeen',
|
||||
sector: 10,
|
||||
});
|
||||
assert.equal(fallback.players[0].reserves, 19);
|
||||
conserveNoFieldForces(fallback);
|
||||
});
|
||||
|
||||
void test('Advanced Guild allows the saved companion once and hides its private declaration proof', () => {
|
||||
const g = smugglerNoFieldGame(true);
|
||||
const action = smugglerNoFieldAction(g);
|
||||
const pending = applyAction(g, 'r', action);
|
||||
assert.equal(pending.decision?.kind, 'guildShipment');
|
||||
assert.equal(pending.pendingShipment?.amount, 1);
|
||||
assert.equal(pending.players[0].reserves, 20);
|
||||
for (const p of pending.players) {
|
||||
const view = JSON.stringify(viewGame(pending, p.id));
|
||||
assert.equal(view.includes('noFieldSkillProof'), false);
|
||||
if (p.id !== 'r')
|
||||
assert.equal(view.includes(String(action.noField)), false);
|
||||
}
|
||||
const done = applyAction(JSON.parse(JSON.stringify(pending)), 'g', {
|
||||
type: 'decision',
|
||||
allow: true,
|
||||
});
|
||||
assert.equal(done.players[0].reserves, 19);
|
||||
assert.equal(done.players[0].forces['arrakeen:10'], 1);
|
||||
conserveNoFieldForces(done);
|
||||
reject(done, 'g', { type: 'decision', allow: true });
|
||||
});
|
||||
|
||||
void test('both saved No-Field stages reject changed companion, proof, destination and missing continuation before mutation', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const g = smugglerNoFieldGame(advanced);
|
||||
if (!advanced) karama(g);
|
||||
const pending = applyAction(g, 'r', smugglerNoFieldAction(g));
|
||||
const corruptions: ((g: Game) => void)[] = [
|
||||
(x) => {
|
||||
delete x.pendingShipment!.smugglerCompanion;
|
||||
},
|
||||
(x) => {
|
||||
x.pendingShipment!.smugglerCompanion!.leader = 'stale';
|
||||
},
|
||||
(x) => {
|
||||
delete x.pendingShipment!.noFieldSkillProof;
|
||||
},
|
||||
(x) => {
|
||||
x.pendingShipment!.noFieldSkillProof = 'stale';
|
||||
},
|
||||
(x) => {
|
||||
x.pendingShipment!.amount = 2;
|
||||
},
|
||||
(x) => {
|
||||
x.pendingShipment!.cost = 0;
|
||||
},
|
||||
(x) => {
|
||||
x.pendingShipment!.territory = 'carthag';
|
||||
},
|
||||
(x) => {
|
||||
x.pendingShipment = null;
|
||||
},
|
||||
(x) => {
|
||||
x.response = null;
|
||||
x.decision = null;
|
||||
},
|
||||
(x) => {
|
||||
x.players[0].reserves = 0;
|
||||
x.players[0].tanks = 20;
|
||||
},
|
||||
(x) => {
|
||||
x.players[1].reserves--;
|
||||
x.players[1].forces['arrakeen:10'] = 1;
|
||||
},
|
||||
];
|
||||
for (const corrupt of corruptions) {
|
||||
const bad = structuredClone(pending);
|
||||
corrupt(bad);
|
||||
assert.throws(() => viewGame(bad, 'r'));
|
||||
assert.throws(() => normalizeAutomaticGame(bad));
|
||||
reject(
|
||||
bad,
|
||||
advanced ? 'g' : 'h',
|
||||
advanced ? { type: 'decision', allow: true } : { type: 'pass' },
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user