Add a local faction-only prototype profile for all seven nonempty expansion selections, genuine Basic/Advanced initialization and existing private setup, controls, AI and saved continuation. Ecaz allocates six forces across Imperial Basin sectors and retains fourteen reserves before later setup; preserve Fremen, Advanced BG and Moritani ordering with printed and inferred boundaries explicit. Keep optional Ecaz Treachery cards separate, ordinary deck counts 33/35/47, and Richese cache separate. Public start and publication gates remain closed. Replace the Advanced Ixian/Richese pre-offer stop with an explicit per-lot Ixian decline. Bind the saved original offer to its round, turn, owner, independent event, exact card custody and terms before view/action/normalization. Decline resumes that unchanged lot once without using Technology; later normal lots still offer the exchange. Protect reserved cards across transfers, spending and random Harkonnen exchange, and preserve legitimate Gift/Truthtrance interruptions. The actual special-lot exchange still awaits replacement-custody interpretation. Validation: focused setup, rules, UI/bot, private JSON/SQLite reload and concurrent CAS tests and independent custody/persistence review passed. Typecheck, lint, 4573 offline tests, production build and 43 HTTP tests passed. Browser Ecaz setup rejected an incomplete total, saved a 2/2/2 split, and refreshed to fourteen reserves and unchanged private cards. A separate genuine Advanced QA game restored the Ixian choice; explicit decline opened the unchanged cache lot with legal bidding and retained private hand, spice, pool count and unused Technology after refresh. Five of six genuine four-profile samples finish: CHOAM Basic 1054 actions/28 JSON restores, CHOAM Advanced 225/6, Ecaz Basic 91/2, Ecaz Advanced 523/14, all-three Basic 476/12. Combined Advanced passes three former auction stops and reaches turn three/action614 with 16 restores, then hits an existing CHOAM movement/Ambassador arrival guard. Ecaz samples also include guarded arrival candidates with legal alternatives. Full combined play is not certified; arrival integration is the next gap. Retain initial failed logs, including a corrected private harness sold-card receipt alias. After the outage, all 597 opening rooms and original seat/recovery records were preserved (632 after QA/HTTP). A stale development module cache required restart after a fresh 598-room backup; all 598 matched afterward and the QA seat restored. Final integrity and foreign keys passed. No database reset, recurring automation or publication. Verified source tree: 5e2ff244a1167f2834bb46d609b8de914a34f7049ff7b06046243068c0935131
143 lines
4.6 KiB
TypeScript
143 lines
4.6 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { createElement } from 'react';
|
|
import { renderToStaticMarkup } from 'react-dom/server';
|
|
import { IxRicheseTechnology } from '../components/ix-richese-technology';
|
|
import { RicheseAuctionDecision } from '../components/richese-auctions';
|
|
import {
|
|
applyAction,
|
|
createGame,
|
|
initializeFactionExpansionsGameForAudit,
|
|
joinGame,
|
|
newPlayer,
|
|
viewGame,
|
|
type Game,
|
|
} from '../game/engine';
|
|
import { botActions } from '../game/bots';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
|
|
function until(state: Game, kind: string) {
|
|
let game = state;
|
|
for (let step = 0; step < 100; step++) {
|
|
if (game.decision?.kind === kind) return game;
|
|
let next: Game | undefined;
|
|
for (const player of game.players) {
|
|
const action = botActions(viewGame(game, player.id))[0];
|
|
if (action) {
|
|
next = applyAction(game, player.id, action);
|
|
break;
|
|
}
|
|
}
|
|
assert.ok(next, `No action before ${kind}`);
|
|
game = next;
|
|
}
|
|
throw new Error(`Did not reach ${kind}`);
|
|
}
|
|
|
|
function offer(source: 'cache' | 'blackMarket') {
|
|
let game = createGame(
|
|
'IXRICHUI',
|
|
newPlayer('r', 'Richese', 'richese'),
|
|
true,
|
|
['ix', 'choam'],
|
|
);
|
|
joinGame(game, newPlayer('i', 'Ixians', 'ixians'));
|
|
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
|
|
for (const player of game.players) {
|
|
player.ready = true;
|
|
player.bot = 'Medium';
|
|
}
|
|
game = until(
|
|
initializeFactionExpansionsGameForAudit(game),
|
|
'richeseBlackMarket',
|
|
);
|
|
if (source === 'cache') {
|
|
game = applyAction(game, 'r', {
|
|
type: 'decision',
|
|
event: game.richeseBidding!.event,
|
|
decline: true,
|
|
});
|
|
game = applyAction(game, 'r', {
|
|
type: 'decision',
|
|
event: game.richeseBidding!.event,
|
|
position: 'first',
|
|
});
|
|
game = until(game, 'richeseCache');
|
|
}
|
|
const markup = renderToStaticMarkup(
|
|
createElement(RicheseAuctionDecision, {
|
|
game: viewGame(game, 'r'),
|
|
act() {},
|
|
busy: false,
|
|
}),
|
|
);
|
|
assert.match(markup, /Offer (?:cache|Black Market) card/);
|
|
assert.doesNotMatch(markup, /<button[^>]*\sdisabled=""[^>]*>Offer /);
|
|
const card =
|
|
source === 'cache' ? game.richeseCache![0] : game.players[0].hand[0];
|
|
game = applyAction(game, 'r', {
|
|
type: 'decision',
|
|
event: game.richeseBidding!.event,
|
|
card: card.id,
|
|
method: 'silent',
|
|
...(source === 'blackMarket' ? { claim: 'Private card claim' } : {}),
|
|
});
|
|
assert.equal(game.decision?.kind, 'ixRicheseTechnology');
|
|
return { game, card };
|
|
}
|
|
|
|
function render(game: Game, viewer = 'i', busy = false) {
|
|
return renderToStaticMarkup(
|
|
createElement(IxRicheseTechnology, {
|
|
game: viewGame(game, viewer),
|
|
act() {},
|
|
busy,
|
|
}),
|
|
);
|
|
}
|
|
|
|
void test('Ixian special-lot decline is owned, available after reload and disabled during submission', () => {
|
|
for (const source of ['cache', 'blackMarket'] as const) {
|
|
const { game, card } = offer(source);
|
|
const restored = JSON.parse(JSON.stringify(game)) as Game;
|
|
const html = render(restored);
|
|
assert.match(html, /Decline Technology for this lot/);
|
|
assert.match(html, /once-per-round Technology use available/);
|
|
assert.match(html, /custody/);
|
|
assert.doesNotMatch(html, new RegExp(card.id));
|
|
if (source === 'blackMarket') assert.ok(!html.includes(card.name));
|
|
assert.equal(render(restored, 'r'), '');
|
|
assert.equal(render(restored, 'a'), '');
|
|
assert.match(render(restored, 'i', true), /<button[^>]*\sdisabled=""/);
|
|
const completed = applyAction(restored, 'i', {
|
|
type: 'decision',
|
|
event: viewGame(restored, 'i').ixRicheseTechnology!.event,
|
|
decline: true,
|
|
});
|
|
assert.equal(render(completed), '');
|
|
assert.equal(completed.ixTechnologyTurn, game.ixTechnologyTurn);
|
|
assert.equal(completed.richeseAuction?.cardId, card.id);
|
|
}
|
|
});
|
|
|
|
void test('all four AI levels decline only this lot using the private owner view', () => {
|
|
for (const source of ['cache', 'blackMarket'] as const) {
|
|
const { game, card } = offer(source);
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const view = viewGame(game, 'i');
|
|
view.players.find((player) => player.id === 'i')!.bot = difficulty;
|
|
const actions = botActions(view);
|
|
assert.deepEqual(actions, [
|
|
{
|
|
type: 'decision',
|
|
event: view.ixRicheseTechnology!.event,
|
|
decline: true,
|
|
},
|
|
]);
|
|
const result = applyAction(game, 'i', actions[0]);
|
|
assert.equal(result.richeseAuction?.cardId, card.id);
|
|
assert.equal(result.ixTechnologyTurn, game.ixTechnologyTurn);
|
|
}
|
|
}
|
|
});
|