Connect bounded Fremen Nexus worm-riding Betrayal
This commit is contained in:
1 parent
66a427ab55
commit
54cbf4d06f
14 files changed
+457
-55
No files matched your search
@@ -1,9 +1,9 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, viewGame, type Action } from '../game/engine';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { TERRITORIES, distance, location, splitLocation } from '../game/board';
|
||||
import {
|
||||
finishNexusSpice, nexusInventory, nexusPlayer, nexusReady, nexusTurnTwo,
|
||||
orderNexusSpice,
|
||||
enterNexusSpice, finishNexusSpice, nexusInventory, nexusPlayer, nexusReady,
|
||||
nexusTurnTwo, orderNexusSpice,
|
||||
} from './fixture-nexus-cards';
|
||||
|
||||
export const fremenBetrayalSource = 'red_chasm:7';
|
||||
@@ -76,3 +76,44 @@ export function fremenBetrayalFixture(
|
||||
assert.equal(offer.blocked, null);
|
||||
return { g, play: { type: 'nexusFremenBetrayal', event: offer.event } as Action };
|
||||
}
|
||||
|
||||
/** Preserve the genuinely drawn card into the next turn's clean pre-blow window. */
|
||||
export function fremenBetrayalWormFixture(
|
||||
advanced = false,
|
||||
ids: [string, string, string] = ['f', 'a', 'h'],
|
||||
) {
|
||||
const previous = fremenBetrayalFixture(advanced, ids);
|
||||
const g = enterNexusSpice(previous.g, true);
|
||||
assert.equal(g.phase, 1);
|
||||
const offer = viewGame(g, ids[2]).nexusFremenBetrayal!;
|
||||
assert.equal(offer.mode, 'worm');
|
||||
assert.equal(offer.blocked, null);
|
||||
return { g, play: { type: 'nexusFremenBetrayal', event: offer.event } as Action };
|
||||
}
|
||||
|
||||
/** Preserve the physical Spice Deck census while choosing a real next appearance. */
|
||||
export function fremenBetrayalNextWorm(g: Game, target: string) {
|
||||
const cards = [...g.spiceDeck, ...g.spiceDiscard.flat()];
|
||||
const landIndex = cards.findIndex(card => 'territory' in card && card.territory === target);
|
||||
assert.ok(landIndex >= 0, `Spice deck needs ${target}`);
|
||||
const land = cards.splice(landIndex, 1)[0];
|
||||
const wormIndex = cards.findIndex(card => 'worm' in card && !card.suppressed);
|
||||
assert.ok(wormIndex >= 0, 'Spice deck needs a natural worm');
|
||||
const worm = cards.splice(wormIndex, 1)[0];
|
||||
g.spiceDeck = [worm, ...cards];
|
||||
g.spiceDiscard = [[land], []];
|
||||
}
|
||||
|
||||
/** Recreate the exact six-field receipt persisted by the movement-only checkpoint. */
|
||||
export function useLegacyFremenMovementReceipt(g: Game) {
|
||||
const history = g.nexusFremenBetrayalHistory!;
|
||||
const record = history.at(-1)!;
|
||||
assert.equal(record.mode, 'movement');
|
||||
const legacy = {
|
||||
event: JSON.stringify(['nexusFremenBetrayal', record.turn, record.owner, record.target]),
|
||||
owner: record.owner, target: record.target, turn: record.turn,
|
||||
phase: 5 as const, signature: '',
|
||||
};
|
||||
legacy.signature = JSON.stringify({ ...legacy, signature: undefined });
|
||||
history[history.length - 1] = legacy;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { NexusFremenBetrayal } from '../components/nexus-fremen-betrayal';
|
||||
import { applyAction, viewGame } from '../game/engine';
|
||||
import { fremenBetrayalWormFixture } from './fixture-nexus-fremen-betrayal';
|
||||
|
||||
void test('pre-blow owner sees worm prevention while rival cannot see the held card or offer', () => {
|
||||
const { g, play } = fremenBetrayalWormFixture();
|
||||
const owner = renderToStaticMarkup(createElement(NexusFremenBetrayal, {
|
||||
game: viewGame(g, 'h'), act() {}, busy: false,
|
||||
}));
|
||||
const rival = renderToStaticMarkup(createElement(NexusFremenBetrayal, {
|
||||
game: viewGame(g, 'f'), act() {}, busy: false,
|
||||
}));
|
||||
assert.match(owner, /Use Betrayal: prevent worm riding this turn/);
|
||||
assert.match(owner, /Worm destruction, survival, protection and Nexus still resolve/);
|
||||
assert.equal(rival, '');
|
||||
const spent = applyAction(g, 'h', play);
|
||||
assert.equal(renderToStaticMarkup(createElement(NexusFremenBetrayal, {
|
||||
game: viewGame(spent, 'h'), act() {}, busy: false,
|
||||
})), '');
|
||||
assert.equal(viewGame(spent, 'f').players.find(player => player.id === 'f')!.fremenNexusWormBlocked, true);
|
||||
});
|
||||
@@ -2,7 +2,11 @@ import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { RoomsClock } from '../db/rooms';
|
||||
import { nexusPlayer } from './fixture-nexus-cards';
|
||||
import { fremenBetrayalFixture, fremenBetrayalMovement } from './fixture-nexus-fremen-betrayal';
|
||||
import {
|
||||
fremenBetrayalFixture, fremenBetrayalMovement,
|
||||
fremenBetrayalNextWorm, fremenBetrayalWormFixture,
|
||||
useLegacyFremenMovementReceipt,
|
||||
} from './fixture-nexus-fremen-betrayal';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
|
||||
const clock: RoomsClock = { now: () => 45000, sleep: async () => {} };
|
||||
@@ -48,4 +52,74 @@ void test('concurrent authenticated Fremen Betrayal commits one physical card an
|
||||
await assert.rejects(store.restart().act(code, freshAuth, saved.version, fremenBetrayalMovement(2), clock));
|
||||
await assert.rejects(store.restart().act(code, auth[2], saved.version, play, clock));
|
||||
assert.deepEqual(store.sqlite.prepare('SELECT state, version FROM rooms WHERE code = ?').get(code), stable);
|
||||
const legacy = await store.restart().readRoom(code);
|
||||
useLegacyFremenMovementReceipt(legacy);
|
||||
store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
|
||||
.run(JSON.stringify(legacy), legacy.version, code);
|
||||
const oldRow = store.sqlite.prepare('SELECT state, version FROM rooms WHERE code = ?').get(code);
|
||||
const recoveredView = await store.restart().readSeatView(code, freshAuth);
|
||||
assert.equal(recoveredView.players.find(player => player.id === fremen)!.fremenMovementBlocked, true);
|
||||
assert.deepEqual(store.sqlite.prepare('SELECT state, version FROM rooms WHERE code = ?').get(code), oldRow);
|
||||
await store.restart().act(code, freshAuth, legacy.version, fremenBetrayalMovement(1), clock);
|
||||
const progressed = await store.restart().readRoom(code);
|
||||
assert.equal(nexusPlayer(progressed, fremen).moved, 1);
|
||||
});
|
||||
|
||||
void test('pre-blow Fremen Betrayal survives concurrent SQL spend and an actual worm without a ride', async t => {
|
||||
const store = unitStore();
|
||||
t.after(() => store.sqlite.close());
|
||||
const created = await store.rooms.createRoom('Fremen Worm SQL', 'fremen', false, []);
|
||||
const code = created.view.code;
|
||||
const atreides = await store.rooms.joinRoom(code, 'Atreides', 'atreides');
|
||||
const harkonnen = await store.rooms.joinRoom(code, 'Harkonnen', 'harkonnen');
|
||||
const tokens = [created.token, atreides.token!, harkonnen.token!];
|
||||
const auth = await Promise.all(tokens.map(token => store.rooms.authenticate(code, token)));
|
||||
const ids = auth.map(seat => seat.playerId) as [string, string, string];
|
||||
const [fremen, , holder] = ids;
|
||||
const { g, play } = fremenBetrayalWormFixture(false, ids);
|
||||
fremenBetrayalNextWorm(g, 'red_chasm');
|
||||
g.code = code;
|
||||
g.version = (await store.rooms.readRoom(code)).version;
|
||||
store.sqlite.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
|
||||
.run(JSON.stringify(g), g.version, code);
|
||||
store.writes.length = 0;
|
||||
assert.equal((await store.rooms.readSeatView(code, auth[0])).nexusFremenBetrayal, null);
|
||||
assert.equal((await store.rooms.readSeatView(code, auth[2])).nexusFremenBetrayal?.mode, 'worm');
|
||||
const outcomes = await Promise.allSettled([
|
||||
store.restart().act(code, auth[2], g.version, play, clock),
|
||||
store.restart().act(code, auth[2], g.version, play, clock),
|
||||
]);
|
||||
assert.equal(outcomes.filter(result => result.status === 'fulfilled').length, 1);
|
||||
assert.equal(outcomes.filter(result => result.status === 'rejected').length, 1);
|
||||
let saved = await store.restart().readRoom(code);
|
||||
assert.equal(saved.nexusFremenBetrayalHistory?.[0].mode, 'worm');
|
||||
assert.equal(nexusPlayer(saved, fremen).fremenNexusWormBlockedTurn, saved.turn);
|
||||
assert.equal(saved.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1);
|
||||
for (let i = 0; i < auth.length; i++) {
|
||||
if (!saved.ready.includes(auth[i].playerId)) {
|
||||
await store.restart().act(code, auth[i], saved.version, { type: 'ready' }, clock);
|
||||
saved = await store.restart().readRoom(code);
|
||||
}
|
||||
}
|
||||
for (let step = 0; saved.response && step < 8; step++) {
|
||||
const id = saved.players.find(player => !saved.response!.passed.includes(player.id))!.id;
|
||||
const index = auth.findIndex(seat => seat.playerId === id);
|
||||
await store.restart().act(code, auth[index], saved.version, { type: 'passResponse' }, clock);
|
||||
saved = await store.restart().readRoom(code);
|
||||
}
|
||||
assert.equal(saved.nexus, true);
|
||||
assert.deepEqual(saved.wormRides, []);
|
||||
assert.notEqual(saved.decision?.kind, 'wormRide');
|
||||
assert.equal(saved.nexusFremenCunningOffer ?? null, null);
|
||||
assert.equal(nexusPlayer(saved, fremen).forces['red_chasm:7'], 4);
|
||||
for (const token of tokens) {
|
||||
const seat = await store.restart().authenticate(code, token);
|
||||
const view = await store.restart().readSeatView(code, seat);
|
||||
assert.equal(view.players.find(player => player.id === fremen)!.fremenNexusWormBlocked, true);
|
||||
assert.equal(view.nexusFremenBetrayal, null);
|
||||
}
|
||||
const before = store.sqlite.prepare('SELECT state, version FROM rooms WHERE code = ?').get(code);
|
||||
await assert.rejects(store.restart().act(code, auth[2], saved.version, play, clock));
|
||||
assert.deepEqual(store.sqlite.prepare('SELECT state, version FROM rooms WHERE code = ?').get(code), before);
|
||||
assert.equal(saved.nexusCards!.cards!.hands[holder], null);
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, normalizeAutomaticGame, viewGame, type Action, type Game,
|
||||
} from '../game/engine';
|
||||
import { botActions, runBots } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import {
|
||||
finishNexusSpice, nexusAllow, nexusInventory, nexusPlayer, nexusReady, nexusReload,
|
||||
} from './fixture-nexus-cards';
|
||||
import { fremenBetrayalNextWorm as nextWorm, fremenBetrayalWormFixture } from './fixture-nexus-fremen-betrayal';
|
||||
|
||||
const fremen = 'f';
|
||||
const holder = 'h';
|
||||
function rejected(g: Game, id: string, action: Action, pattern?: RegExp) {
|
||||
const before = structuredClone(g);
|
||||
if (pattern) assert.throws(() => applyAction(g, id, action), pattern);
|
||||
else assert.throws(() => applyAction(g, id, action));
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} pre-blow Betrayal prevents native ride but retains worm destruction and Nexus`, () => {
|
||||
const { g, play } = fremenBetrayalWormFixture(advanced);
|
||||
assert.equal(viewGame(g, holder).nexusFremenBetrayal?.mode, 'worm');
|
||||
assert.equal(viewGame(g, fremen).nexusFremenBetrayal, null);
|
||||
rejected(g, fremen, play);
|
||||
rejected(g, holder, { ...play, event: 'stale' });
|
||||
nextWorm(g, 'red_chasm');
|
||||
let state = applyAction(nexusReload(g), holder, play);
|
||||
assert.equal(state.nexusCards!.cards!.hands[holder], null);
|
||||
assert.equal(state.nexusCards!.cards!.discard.filter(card => card === 'fremen').length, 1);
|
||||
assert.equal(nexusPlayer(state, fremen).fremenNexusWormBlockedTurn, state.turn);
|
||||
assert.equal(viewGame(state, fremen).players.find(p => p.id === fremen)!.fremenNexusWormBlocked, true);
|
||||
nexusInventory(state);
|
||||
rejected(state, holder, play);
|
||||
state = nexusReady(nexusReload(state));
|
||||
if (state.response) state = nexusAllow(state);
|
||||
assert.equal(state.nexus, true);
|
||||
assert.equal(state.nexusCards!.phase!.occurred, true);
|
||||
assert.deepEqual(state.wormRides, []);
|
||||
assert.notEqual(state.decision?.kind, 'wormRide');
|
||||
assert.equal(state.nexusFremenCunningOffer ?? null, null);
|
||||
assert.equal(nexusPlayer(state, fremen).forces['red_chasm:7'], 4);
|
||||
const restored = normalizeAutomaticGame(nexusReload(state));
|
||||
assert.equal(restored.nexusFremenBetrayalHistory?.[0].mode, 'worm');
|
||||
assert.equal(nexusPlayer(restored, fremen).fremenNexusWormBlockedTurn, restored.turn);
|
||||
});
|
||||
}
|
||||
|
||||
void test('a later empty worm offers no remote Cunning ride while Betrayal is active', () => {
|
||||
const { g, play } = fremenBetrayalWormFixture();
|
||||
nextWorm(g, 'hagga_basin');
|
||||
let state = applyAction(g, holder, play);
|
||||
state = nexusReady(state);
|
||||
if (state.response) state = nexusAllow(state);
|
||||
assert.equal(state.nexus, true);
|
||||
assert.equal(state.nexusFremenCunningOffer ?? null, null);
|
||||
assert.equal(state.decision?.kind === 'nexusFremenCunningOffer', false);
|
||||
assert.deepEqual(state.wormRides, []);
|
||||
});
|
||||
|
||||
void test('an unspent holder cannot retroactively stop a worm after its appearance', () => {
|
||||
const { g, play } = fremenBetrayalWormFixture();
|
||||
nextWorm(g, 'hagga_basin');
|
||||
let state = nexusReady(g);
|
||||
if (state.response) state = nexusAllow(state);
|
||||
assert.equal(state.nexus, true);
|
||||
assert.equal(viewGame(state, holder).nexusFremenBetrayal?.mode, 'worm');
|
||||
assert.match(String(viewGame(state, holder).nexusFremenBetrayal?.blocked), /before the first worm/);
|
||||
rejected(state, holder, play);
|
||||
assert.equal(state.nexusCards!.cards!.hands[holder], 'fremen');
|
||||
});
|
||||
|
||||
void test('post-blow play is too late, and a recycled current-turn card cannot suppress movement again', () => {
|
||||
const { g, play } = fremenBetrayalWormFixture();
|
||||
nextWorm(g, 'hagga_basin');
|
||||
let state = applyAction(g, holder, play);
|
||||
state = nexusReady(state);
|
||||
if (state.response) state = nexusAllow(state);
|
||||
state = finishNexusSpice(state);
|
||||
assert.equal(state.nexusCards!.phase!.stage, 'drawing');
|
||||
// The physical card may be recycled during this phase's Nexus draw.
|
||||
const cards = state.nexusCards!.cards!;
|
||||
cards.discard = cards.discard.filter(card => card !== 'fremen');
|
||||
cards.deck.push('fremen');
|
||||
state = applyAction(state, holder, {
|
||||
type: 'nexusCardChoice', turn: state.turn, card: null, choice: 'keep', ownRedraws: 0,
|
||||
});
|
||||
assert.equal(state.nexusCards!.phase!.stage, 'complete');
|
||||
state.phase = 5;
|
||||
state.active = fremen;
|
||||
state.movementRemaining = [...state.order];
|
||||
nexusPlayer(state, fremen).moved = 0;
|
||||
const recycled = state.nexusCards!.cards!;
|
||||
const old = recycled.hands[holder];
|
||||
if (old) recycled.discard.push(old);
|
||||
recycled.deck = recycled.deck.filter(card => card !== 'fremen');
|
||||
recycled.hands[holder] = 'fremen';
|
||||
nexusInventory(state);
|
||||
assert.match(String(viewGame(state, holder).nexusFremenBetrayal?.blocked), /already spent/);
|
||||
rejected(state, holder, { type: 'nexusFremenBetrayal', event: play.event }, /before|current/);
|
||||
assert.equal(viewGame(state, fremen).players.find(p => p.id === fremen)!.fremenNexusWormBlocked, true);
|
||||
});
|
||||
|
||||
void test('all four bot holders act before a Fremen bot readies the first blow', () => {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const { g, play } = fremenBetrayalWormFixture();
|
||||
nexusPlayer(g, holder).bot = difficulty;
|
||||
nexusPlayer(g, fremen).bot = difficulty;
|
||||
assert.deepEqual(botActions(viewGame(g, holder))[0], play);
|
||||
const first = runBots(g, 1);
|
||||
assert.equal(first.nexusCards!.cards!.hands[holder], null);
|
||||
assert.equal(nexusPlayer(first, fremen).fremenNexusWormBlockedTurn, first.turn);
|
||||
assert.equal(first.ready.includes(fremen), false);
|
||||
}
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import { nexusInventory, nexusPlayer, nexusReload } from './fixture-nexus-cards'
|
||||
import {
|
||||
fremenBetrayalFixture as setup,
|
||||
fremenBetrayalMovement as movement,
|
||||
useLegacyFremenMovementReceipt,
|
||||
} from './fixture-nexus-fremen-betrayal';
|
||||
|
||||
const fremen = 'f';
|
||||
@@ -105,3 +106,24 @@ void test('changed saved turn, target or suppression rejects before advancing mo
|
||||
rejected(damaged, fremen, movement(1));
|
||||
}
|
||||
});
|
||||
|
||||
void test('validated movement-only receipts remain readable and executable after JSON restoration', () => {
|
||||
const { g, play } = setup();
|
||||
const played = applyAction(g, holder, play);
|
||||
useLegacyFremenMovementReceipt(played);
|
||||
const saved = nexusReload(played);
|
||||
assert.equal(viewGame(saved, fremen).players.find(p => p.id === fremen)!.fremenMovementBlocked, true);
|
||||
const restored = normalizeAutomaticGame(nexusReload(saved));
|
||||
assert.deepEqual(restored.nexusFremenBetrayalHistory, saved.nexusFremenBetrayalHistory);
|
||||
const moved = applyAction(restored, fremen, movement(1));
|
||||
assert.equal(nexusPlayer(moved, fremen).moved, 1);
|
||||
for (const corrupt of [
|
||||
(state: Game) => { state.nexusFremenBetrayalHistory![0].event = 'forged'; },
|
||||
(state: Game) => { state.nexusFremenBetrayalHistory![0].signature = 'forged'; },
|
||||
]) {
|
||||
const damaged = nexusReload(saved);
|
||||
corrupt(damaged);
|
||||
assert.throws(() => viewGame(damaged, fremen));
|
||||
rejected(damaged, fremen, movement(1));
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user