Extend bounded Basic ground-movement reachability to held native CHOAM Kulon and owned paid Box recovery. Before payment, use only known current-discard identities. Preserve actual paid event, full inspection, one two-spice fee, original physical card replay and existing provisional full-hand exchange. Reject a Yes-breaking selection before the server shuffle without losing the paid search. Keep No-only searches on their normal decision before movement can end. Record movement-relevant current-discard certainty at actual disposal, paid search, mixed claim and deck rebuild boundaries without projecting raw metadata or erasing historical memory. Use deterministic preparation on private copies, include preparation state in search memoization, protect fee-funded routes from voluntary spending and keep native Kulon counter semantics. Persistent busy-safe guidance and existing legal profiles use the actual next paid action; no strategy tuning. Verify 17 files231/231, types/lint/build, three original-action control/table-rendering programmes and four existing profiles with40 accepted actions including neutral passes. Original public sources44/25 retain actual purchases1+1, shipment costs6/1, exhausted Hajr and native Kulon route4. Separate full-hand, legacy certainty, fee-loss and acquired-counter edges disclose conserved staging. Two prior-engine Auditor fixture failures are repaired by actual battle-result acknowledgements; test-only const lint is corrected. Other timing, preparations/counter mitigation, unknown private possibilities, optional/Advanced, full modes, comprehensive assurance and deployed acceptance remain open. No saved-room/reset/restart/deployment operation.
164 lines
11 KiB
TypeScript
164 lines
11 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { botActions } from '../game/bots';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
import { TERRITORIES, gameDistance, splitLocation } from '../game/board';
|
|
import { createTruthMovementChoamFixture } from './fixture-truth-movement-choam';
|
|
import { viewGame, normalizeAutomaticGame, type Game } from '../game/engine';
|
|
import { createTruthMovementBoxFixture, actTruthMovementBox as act } from './fixture-truth-movement-box';
|
|
|
|
function ask(game: Game, asker: string, card: string, question: Record<string, unknown>) {
|
|
game = act(game, asker, { type: 'card', card });
|
|
for (const p of game.players) if (!game.truthtrance!.passed.includes(p.id)) game = act(game, p.id, { type: 'truthPass' });
|
|
return act(game, asker, { type: 'truthAsk', question });
|
|
}
|
|
void test('actual owned paid Box recovery makes a third Basic movement promise feasible', () => {
|
|
const f = createTruthMovementBoxFixture();
|
|
const game = ask(f.game, f.asker, f.truth, f.claim);
|
|
assert.ok(viewGame(game, f.target).truthMovementAnswers!.includes('yes'));
|
|
});
|
|
|
|
function passDiscard(game: Game): Game {
|
|
if (!game.pendingTreacheryDiscard?.reaction) return game;
|
|
const event = game.pendingTreacheryDiscard.batch.event;
|
|
for (const p of game.players) game = act(game, p.id, { type: 'semutaPass', event });
|
|
return game;
|
|
}
|
|
function bindBox(answer: 'yes' | 'no' = 'yes') {
|
|
const f = createTruthMovementBoxFixture();
|
|
let game = ask(f.game, f.asker, f.truth, f.claim);
|
|
game = act(game, f.target, { type: 'truthAnswer', answer });
|
|
game = passDiscard(game); return { f, game };
|
|
}
|
|
void test('the actual paid search selects required Hajr once and finishes the third move without a second fee', () => {
|
|
const { f, game: bound } = bindBox(); let game = bound;
|
|
const before = structuredClone(game), first = viewGame(game, f.target).movementPromisePolicy?.[0];
|
|
assert.deepEqual(first, { type: 'card', card: f.box });
|
|
assert.deepEqual(game, before, 'Private feasibility cannot pay or shuffle the live game');
|
|
game = act(game, f.target, first!);
|
|
assert.equal(game.players.find(p => p.id === f.target)!.spice, 0);
|
|
const paid = viewGame(game, f.target).nullentropy!.search!;
|
|
assert.ok(paid.cards.some(card => card.id === f.hajr)); assert.ok(paid.cards.some(card => card.id === f.truth));
|
|
const required = viewGame(game, f.target).movementPromisePolicy?.[0];
|
|
assert.deepEqual(required, { type: 'decision', event: paid.event, card: f.hajr });
|
|
const snapshot = structuredClone(game);
|
|
assert.throws(() => act(game, f.target, { type: 'decision', event: paid.event, card: f.truth }));
|
|
assert.deepEqual(game, snapshot, 'Rejected selection keeps the paid event and fee');
|
|
assert.throws(() => act(game, f.target, { type: 'decision', event: 'stale', card: f.hajr }));
|
|
game = act(game, f.target, required!); game = passDiscard(game);
|
|
assert.equal(game.pendingNullentropy, null);
|
|
const hajr = viewGame(game, f.target).movementPromisePolicy?.[0]; assert.deepEqual(hajr, { type: 'card', card: f.hajr });
|
|
game = act(game, f.target, hajr!); game = passDiscard(game);
|
|
game = act(game, f.target, viewGame(game, f.target).movementPromisePolicy![0]);
|
|
assert.equal(game.movementPromises![0].fulfilled, true);
|
|
game = act(game, f.target, { type: 'endMovement' });
|
|
assert.equal(game.players.find(p => p.id === f.target)!.spice, 0);
|
|
assert.equal(game.players.find(p => p.id === f.target)!.hajrPlays, 2);
|
|
assert.equal(game.discard.filter(card => card.id === f.box).length, 1);
|
|
assert.equal(game.discard.filter(card => card.id === f.hajr).length, 1);
|
|
});
|
|
|
|
void test('a No-only paid search retains its full original choice and never proposes End movement while searching', () => {
|
|
const { f, game: bound } = bindBox('no'); let game = act(bound, f.target, { type: 'card', card: f.box });
|
|
assert.equal(viewGame(game, f.target).movementPromisePolicy, null);
|
|
const search = viewGame(game, f.target).nullentropy!.search!;
|
|
assert.ok(search.cards.some(card => card.id === f.truth));
|
|
game = act(game, f.target, { type: 'decision', event: search.event, card: f.truth });
|
|
game = passDiscard(game);
|
|
assert.equal(game.movementPromises![0].fulfilled, undefined);
|
|
game = act(game, f.target, { type: 'endMovement' });
|
|
assert.equal(game.movementPromises![0].fulfilled, true);
|
|
assert.equal(game.players.find(p => p.id === f.target)!.spice, 0);
|
|
});
|
|
void test('a voluntary bribe cannot spend the Box fee reserved by the only remaining Yes route', () => {
|
|
const { f, game } = bindBox(); const before = structuredClone(game);
|
|
const view = viewGame(game, f.target);
|
|
assert.equal(view.bribeOptions.targets.find(target => target.id === f.asker)!.maximum, 0);
|
|
assert.throws(() => act(game, f.target, { type: 'bribe', target: f.asker, amount: 1 }));
|
|
assert.deepEqual(game, before);
|
|
});
|
|
void test('controlled full-hand recovery keeps the approved atomic Box exchange and original paid event', () => {
|
|
const f = createTruthMovementBoxFixture();
|
|
const source = structuredClone(f.game), owner = source.players.find(p => p.id === f.target)!;
|
|
while (owner.hand.length < 4) owner.hand.push(source.deck.pop()!);
|
|
let game = ask(source, f.asker, f.truth, f.claim);
|
|
assert.ok(viewGame(game, f.target).truthMovementAnswers!.includes('yes'));
|
|
game = passDiscard(act(game, f.target, { type: 'truthAnswer', answer: 'yes' }));
|
|
game = act(game, f.target, viewGame(game, f.target).movementPromisePolicy![0]);
|
|
const search = viewGame(game, f.target).nullentropy!.search!;
|
|
game = act(game, f.target, { type: 'decision', event: search.event, card: f.hajr });
|
|
assert.equal(game.players.find(p => p.id === f.target)!.hand.length, 4);
|
|
assert.equal(game.players.find(p => p.id === f.target)!.spice, 0);
|
|
});
|
|
|
|
void test('controlled legacy certainty cannot inspect hidden Box contents before payment', () => {
|
|
const f = createTruthMovementBoxFixture(), unknown = structuredClone(f.game);
|
|
delete unknown.movementDiscardKnowledge;
|
|
const changed = structuredClone(unknown), index = changed.discard.findIndex(card => card.id === f.hajr);
|
|
const alternate = changed.deck.findIndex(card => card.kind === 'shield'); assert.ok(alternate >= 0);
|
|
[changed.discard[index], changed.deck[alternate]] = [changed.deck[alternate], changed.discard[index]];
|
|
for (const source of [unknown, changed]) {
|
|
const game = ask(source, f.asker, f.truth, f.claim), view = viewGame(game, f.target);
|
|
assert.deepEqual(view.truthMovementAnswers, ['no']);
|
|
assert.equal(Object.hasOwn(view, 'movementDiscardKnowledge'), false);
|
|
assert.equal(view.nullentropy?.search, null);
|
|
}
|
|
});
|
|
void test('a controlled opposing loss of the fee releases the Box-dependent Yes while keeping its actual pieces', () => {
|
|
const { f, game: bound } = bindBox(); const harmed = structuredClone(bound);
|
|
harmed.players.find(p => p.id === f.target)!.spice--;
|
|
harmed.players.find(p => p.id === f.asker)!.spice++;
|
|
const game = normalizeAutomaticGame(harmed);
|
|
assert.equal(game.movementPromises![0].released, true);
|
|
assert.ok(game.players.find(p => p.id === f.target)!.hand.some(card => card.id === f.box));
|
|
assert.equal(game.discard.filter(card => card.id === f.hajr).length, 1);
|
|
});
|
|
function farChoamDestination(game: Game) {
|
|
const destination = TERRITORIES.flatMap(t => t.sectors.map(sector => ({ territory: t.id, sector }))).find(to =>
|
|
gameDistance(game, 'arrakeen:10', `${to.territory}:${to.sector}`, key => {
|
|
const place = splitLocation(key); return place.sector !== 0 && place.sector === game.storm;
|
|
}) === 4);
|
|
assert.ok(destination); return destination;
|
|
}
|
|
void test('a genuinely held Kulon supplies its original native preparation before a promised four-step move', () => {
|
|
const f = createTruthMovementChoamFixture(), source = f.beforeKulon, destination = farChoamDestination(source);
|
|
let game = ask(source, f.asker, f.truth, { kind: 'movement', target: f.target, territory: destination.territory, minimum: 1 });
|
|
assert.ok(viewGame(game, f.target).truthMovementAnswers!.includes('yes'));
|
|
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
|
assert.deepEqual(viewGame(game, f.target).movementPromisePolicy, [{ type: 'card', mode: 'choam', card: f.kulon }]);
|
|
game = act(game, f.target, viewGame(game, f.target).movementPromisePolicy![0]);
|
|
assert.equal(game.choamMovement?.bonus, 1);
|
|
assert.ok(game.movementDiscardKnowledge?.[f.asker].includes(f.kulon), 'Public direct Kulon disposal establishes current-pile certainty');
|
|
game = act(game, f.target, viewGame(game, f.target).movementPromisePolicy![0]);
|
|
assert.equal(game.movementPromises![0].fulfilled, true);
|
|
});
|
|
void test('a controlled real Karama counter keeps Kulon held/blocked and releases the impossible promised route', () => {
|
|
const f = createTruthMovementChoamFixture(), source = structuredClone(f.beforeKulon), destination = farChoamDestination(source);
|
|
const karama = source.deck.splice(source.deck.findIndex(card => card.effect === 'karama'), 1)[0]; assert.ok(karama);
|
|
source.players.find(p => p.id === f.asker)!.hand.push(karama);
|
|
let game = ask(source, f.asker, f.truth, { kind: 'movement', target: f.target, territory: destination.territory, minimum: 1 });
|
|
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
|
game = act(game, f.target, viewGame(game, f.target).movementPromisePolicy![0]);
|
|
assert.equal(game.response?.kind, 'choamWorthless');
|
|
assert.equal(game.movementPromises![0].released, undefined, 'A pending opposing choice does not release the answer');
|
|
game = act(game, f.asker, { type: 'card', card: karama.id, mode: 'cancel' });
|
|
assert.equal(game.movementPromises![0].released, true);
|
|
assert.ok(game.players.find(p => p.id === f.target)!.hand.some(card => card.id === f.kulon));
|
|
assert.equal(game.choamMovement, undefined);
|
|
});
|
|
void test('all four minimal profiles select the required original paid event and complete real Box recovery', () => {
|
|
const { f, game: bound } = bindBox();
|
|
for (const difficulty of DIFFICULTIES) {
|
|
let game = act(bound, f.target, { type: 'card', card: f.box }), count = 0;
|
|
while (game.phase === 5 && game.active === f.target && count++ < 20) {
|
|
if (game.pendingTreacheryDiscard) { game = passDiscard(game); continue; }
|
|
const view = viewGame(game, f.target); view.players.find(p => p.id === f.target)!.bot = difficulty;
|
|
const action = botActions(view)[0]; assert.ok(action);
|
|
if (game.pendingNullentropy) assert.deepEqual(action, { type: 'decision', event: game.pendingNullentropy.event, card: f.hajr });
|
|
game = act(game, f.target, action);
|
|
}
|
|
assert.ok(count < 20); assert.equal(game.movementPromises![0].fulfilled, true);
|
|
assert.equal(game.players.find(p => p.id === f.target)!.spice, 0);
|
|
}
|
|
});
|