Prototype neutral Semuta reactions on clean ordinary discards
This commit is contained in:
1 parent
55b47ce039
commit
9ea50a792e
18 files changed
+908
-20
No files matched your search
@@ -0,0 +1,104 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type GameView } from '../game/engine';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { botSemutaActions } from '../game/bot-semuta';
|
||||
import { richeseCards } from '../game/richese-cards';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
|
||||
const semuta = richeseCards().find((card) => card.id === SEMUTA_DRUG_ID)!;
|
||||
const event = 'discard:3:6:19';
|
||||
|
||||
function offerView(): GameView {
|
||||
const g = createGame('BOTSEMUTA', newPlayer('owner', 'Owner', 'atreides'));
|
||||
joinGame(g, newPlayer('rival', 'Rival', 'fremen'));
|
||||
const v = viewGame(g, 'owner');
|
||||
v.status = 'playing';
|
||||
v.players.find((p) => p.id === 'owner')!.hand!.push(semuta);
|
||||
v.semutaReaction = {
|
||||
event,
|
||||
stage: 'offer',
|
||||
passed: false,
|
||||
canCommit: true,
|
||||
blocked: null,
|
||||
candidates: [],
|
||||
};
|
||||
return v;
|
||||
}
|
||||
|
||||
function denyRivalPrivateFields(v: GameView) {
|
||||
const rival = v.players.find((p) => p.id === 'rival')!;
|
||||
for (const key of ['hand', 'traitors', 'faceDancers', 'spice'])
|
||||
Object.defineProperty(rival, key, {
|
||||
configurable: true,
|
||||
get() { throw new Error(`Read rival private ${key}`); },
|
||||
});
|
||||
}
|
||||
|
||||
void test('all four difficulties commit using only their own card and the exact public event without mutating the projected view', () => {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const v = offerView();
|
||||
v.players.find((p) => p.id === v.me)!.bot = difficulty;
|
||||
const before = structuredClone(v);
|
||||
denyRivalPrivateFields(v);
|
||||
assert.deepEqual(botSemutaActions(v), [{ type: 'semutaCommit', event }]);
|
||||
const rival = v.players.find((p) => p.id === 'rival')!;
|
||||
for (const key of ['hand', 'traitors', 'faceDancers', 'spice'])
|
||||
Reflect.deleteProperty(rival, key);
|
||||
assert.deepEqual(v, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('offer never inspects an uncommitted candidate or rival custody; unable or blocked seats pass once', () => {
|
||||
const v = offerView();
|
||||
const reaction = v.semutaReaction!;
|
||||
Object.defineProperty(reaction, 'candidates', {
|
||||
get() { throw new Error('Read unrevealed fresh cards'); },
|
||||
});
|
||||
denyRivalPrivateFields(v);
|
||||
assert.deepEqual(botSemutaActions(v), [{ type: 'semutaCommit', event }]);
|
||||
v.players.find((p) => p.id === v.me)!.hand!.length = 0;
|
||||
assert.deepEqual(botSemutaActions(v), [{ type: 'semutaPass', event }]);
|
||||
v.players.find((p) => p.id === v.me)!.hand!.push(semuta);
|
||||
reaction.blocked = 'No free hand slot';
|
||||
assert.deepEqual(botSemutaActions(v), [{ type: 'semutaPass', event }]);
|
||||
reaction.blocked = null;
|
||||
reaction.canCommit = false;
|
||||
assert.deepEqual(botSemutaActions(v), [{ type: 'semutaPass', event }]);
|
||||
reaction.passed = true;
|
||||
assert.deepEqual(botSemutaActions(v), []);
|
||||
v.semutaReaction = null;
|
||||
assert.deepEqual(botSemutaActions(v), []);
|
||||
});
|
||||
|
||||
void test('all four difficulties select an exact projected candidate after commitment, never a guessed discard', () => {
|
||||
const [first, second] = baseDeck();
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const v = offerView();
|
||||
v.players.find((p) => p.id === v.me)!.bot = difficulty;
|
||||
v.semutaReaction = {
|
||||
event,
|
||||
stage: 'select',
|
||||
passed: false,
|
||||
canCommit: false,
|
||||
blocked: null,
|
||||
candidates: [first, second],
|
||||
};
|
||||
const before = structuredClone(v);
|
||||
denyRivalPrivateFields(v);
|
||||
assert.deepEqual(botSemutaActions(v), [
|
||||
{ type: 'semutaSelect', event, card: first.id },
|
||||
]);
|
||||
const rival = v.players.find((p) => p.id === 'rival')!;
|
||||
for (const key of ['hand', 'traitors', 'faceDancers', 'spice'])
|
||||
Reflect.deleteProperty(rival, key);
|
||||
assert.deepEqual(v, before);
|
||||
v.semutaReaction.candidates = [second];
|
||||
assert.deepEqual(botSemutaActions(v), [
|
||||
{ type: 'semutaSelect', event, card: second.id },
|
||||
]);
|
||||
v.semutaReaction.candidates = [];
|
||||
assert.deepEqual(botSemutaActions(v), []); // Nonclaimants receive no faces.
|
||||
}
|
||||
});
|
||||
@@ -235,3 +235,24 @@ void test('Moritani assassination preview starts only the fresh opted-in Advance
|
||||
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(),rows);
|
||||
assert.deepEqual(db.prepare('SELECT * FROM seats').all(),seats);assert.deepEqual(db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00'),other);
|
||||
});
|
||||
|
||||
void test('Semuta development profile begins only a fresh Richese lobby without changing saved seats', (t) => {
|
||||
const { db } = fixture(t);
|
||||
const game = createGame('PROTOTYP', newPlayer('r', 'Richese', 'richese'), false, ['choam']);
|
||||
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
|
||||
for (const p of game.players) p.ready = true;
|
||||
db.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(game), game.code);
|
||||
const seats = db.prepare('SELECT * FROM seats').all();
|
||||
const other = db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00');
|
||||
const result = startPrototypeRoom(db, game.code, 7, 'semuta');
|
||||
const saved = JSON.parse(db.prepare('SELECT state FROM rooms WHERE code=?').get(game.code)!.state as string) as Game;
|
||||
assert.equal(result.status, 'setup');
|
||||
assert.equal(saved.semutaPreview, true);
|
||||
assert.ok(saved.richeseCache?.some(card => card.id === 'richese-semuta-drug'));
|
||||
const rows = db.prepare('SELECT * FROM rooms ORDER BY code').all();
|
||||
assert.throws(() => startPrototypeRoom(db, game.code, 7, 'semuta'));
|
||||
assert.throws(() => startPrototypeRoom(db, game.code, 8, 'semuta'));
|
||||
assert.deepEqual(db.prepare('SELECT * FROM rooms ORDER BY code').all(), rows);
|
||||
assert.deepEqual(db.prepare('SELECT * FROM seats').all(), seats);
|
||||
assert.deepEqual(db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00'), other);
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, initializeSemutaGameForAudit, joinGame, newPlayer, normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
|
||||
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
|
||||
const player = (g: Game, id: string) => g.players.find(p => p.id === id)!;
|
||||
function take(g: Game, id: string) {
|
||||
const zones = [g.deck, g.discard, g.richeseCache!, ...g.players.map(p => p.hand)];
|
||||
for (const zone of zones) {
|
||||
const index = zone.findIndex(card => card.id === id || card.effect === id);
|
||||
if (index >= 0) return zone.splice(index, 1)[0];
|
||||
}
|
||||
throw new Error(`Missing physical ${id}`);
|
||||
}
|
||||
function inventory(g: Game) {
|
||||
const ids = [...g.deck, ...g.discard, ...g.richeseCache!, ...g.players.flatMap(p => p.hand)].map(card => card.id).sort();
|
||||
assert.equal(new Set(ids).size, ids.length);
|
||||
return ids;
|
||||
}
|
||||
function fixture() {
|
||||
let g = createGame('SEMUTAQ2', newPlayer('r', 'Richese', 'richese'), false, ['choam']);
|
||||
joinGame(g, newPlayer('a', 'Atreides', 'atreides'));
|
||||
joinGame(g, newPlayer('e', 'Emperor', 'emperor'));
|
||||
for (const p of g.players) { p.bot = 'Medium'; p.ready = true; }
|
||||
g = initializeSemutaGameForAudit(g);
|
||||
for (let step = 0; g.status === 'setup' && step < 80; step++) {
|
||||
let next: Game | undefined;
|
||||
for (const p of g.players) {
|
||||
const action = botActions(viewGame(g, p.id))[0];
|
||||
if (action) { next = applyAction(g, p.id, action); break; }
|
||||
}
|
||||
assert.ok(next, 'genuine setup must have a legal AI choice');
|
||||
g = next;
|
||||
}
|
||||
assert.equal(g.status, 'playing');
|
||||
const semuta = take(g, SEMUTA_DRUG_ID), hajr = take(g, 'hajr');
|
||||
g.deck.push(...g.players.flatMap(p => p.hand));
|
||||
for (const p of g.players) p.hand = [];
|
||||
player(g, 'r').hand.push(semuta);
|
||||
player(g, 'a').hand.push(hajr);
|
||||
Object.assign(g, { turn: 2, phase: 5, active: 'a', storm: 18, ready: [], decision: null,
|
||||
response: null, phaseOpening: null, stormPending: null, movementRemaining: ['a', 'r', 'e'] });
|
||||
return { g, semuta, hajr };
|
||||
}
|
||||
|
||||
void test('a clean public discard offers every seat the same event, then a committed sole Semuta claim resumes once across JSON', () => {
|
||||
const { g, semuta, hajr } = fixture();
|
||||
const original = inventory(g);
|
||||
const pending = applyAction(g, 'a', { type: 'card', card: hajr.id });
|
||||
const event = pending.pendingTreacheryDiscard?.batch.event;
|
||||
assert.equal(pending.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
assert.ok(event);
|
||||
const from = viewGame(pending, 'a').semutaReaction!, to = viewGame(pending, 'r').semutaReaction!;
|
||||
assert.equal(from.event, to.event);
|
||||
assert.equal(from.canCommit, false);
|
||||
assert.equal(to.canCommit, true);
|
||||
assert.deepEqual(from.candidates, []);
|
||||
assert.deepEqual(to.candidates, []);
|
||||
assert.equal(viewGame(pending, 'e').semutaReaction?.event, event);
|
||||
assert.deepEqual(normalizeAutomaticGame(reload(pending)), reload(pending));
|
||||
assert.throws(() => applyAction(pending, 'a', { type: 'move', from: 'imperial_basin:10', amount: 1,
|
||||
territory: 'arrakeen', sector: 10 }));
|
||||
const before = JSON.stringify(pending);
|
||||
assert.throws(() => applyAction(pending, 'a', { type: 'semutaCommit', event }));
|
||||
assert.throws(() => applyAction(pending, 'r', { type: 'semutaSelect', event, card: hajr.id }));
|
||||
assert.equal(JSON.stringify(pending), before);
|
||||
const passed = applyAction(reload(pending), 'a', { type: 'semutaPass', event });
|
||||
assert.equal(viewGame(passed, 'a').semutaReaction?.passed, true);
|
||||
assert.equal(viewGame(passed, 'r').semutaReaction?.passed, false);
|
||||
assert.deepEqual(normalizeAutomaticGame(reload(passed)), reload(passed));
|
||||
const botChoice = botActions(viewGame(passed, 'r'))[0];
|
||||
assert.ok(botChoice);
|
||||
const claimed = applyAction(reload(passed), 'r', botChoice);
|
||||
assert.equal(claimed.pendingTreacheryDiscard, null);
|
||||
assert.equal(claimed.resolvedTreacheryDiscardSequence, claimed.treacheryDiscardSequence);
|
||||
assert.equal(player(claimed, 'r').hand.filter(card => card.id === hajr.id).length, 1);
|
||||
assert.equal(player(claimed, 'r').hand.some(card => card.id === semuta.id), false);
|
||||
assert.equal(claimed.discard.filter(card => card.id === semuta.id).length, 1);
|
||||
assert.equal(claimed.discard.some(card => card.id === hajr.id), false);
|
||||
assert.equal(claimed.hajr.filter(id => id === 'a').length, 1);
|
||||
assert.deepEqual(inventory(claimed), original);
|
||||
for (const p of claimed.players)
|
||||
assert.deepEqual(viewGame(reload(claimed), p.id), viewGame(claimed, p.id));
|
||||
assert.deepEqual(normalizeAutomaticGame(reload(claimed)), reload(claimed));
|
||||
assert.throws(() => applyAction(claimed, 'r', { type: 'semutaCommit', event }));
|
||||
});
|
||||
|
||||
void test('neutral Semuta passes do not reveal hidden possession and resume a declined effect exactly once', () => {
|
||||
const { g, semuta, hajr } = fixture();
|
||||
const without = reload(g);
|
||||
player(without, 'r').hand = [];
|
||||
without.richeseCache!.push(semuta);
|
||||
player(without, 'r').hand.push(take(without, 'richese-distrans'));
|
||||
const held = applyAction(g, 'a', { type: 'card', card: hajr.id });
|
||||
const absent = applyAction(without, 'a', { type: 'card', card: hajr.id });
|
||||
const event = held.pendingTreacheryDiscard!.batch.event;
|
||||
assert.equal(absent.pendingTreacheryDiscard!.batch.event, event);
|
||||
for (const id of ['a', 'e'])
|
||||
assert.deepEqual(viewGame(held, id), viewGame(absent, id));
|
||||
assert.equal(viewGame(absent, 'r').semutaReaction?.canCommit, false);
|
||||
assert.deepEqual(normalizeAutomaticGame(reload(absent)), reload(absent));
|
||||
let declined = reload(held);
|
||||
for (const id of ['e', 'r', 'a']) {
|
||||
declined = applyAction(declined, id, { type: 'semutaPass', event });
|
||||
assert.deepEqual(inventory(declined), inventory(held));
|
||||
}
|
||||
assert.equal(declined.pendingTreacheryDiscard, null);
|
||||
assert.equal(declined.hajr.filter(id => id === 'a').length, 1);
|
||||
assert.equal(declined.discard.filter(card => card.id === hajr.id).length, 1);
|
||||
assert.equal(player(declined, 'r').hand.some(card => card.id === semuta.id), true);
|
||||
assert.deepEqual(normalizeAutomaticGame(reload(declined)), reload(declined));
|
||||
});
|
||||
|
||||
void test('the provisional free-slot guard is private and never spends a full-hand Semuta', () => {
|
||||
const { g, semuta, hajr } = fixture();
|
||||
for (let n = 0; n < 3; n++) player(g, 'r').hand.push(g.deck.shift()!);
|
||||
const pending = applyAction(g, 'a', { type: 'card', card: hajr.id });
|
||||
const event = pending.pendingTreacheryDiscard!.batch.event;
|
||||
const richese = viewGame(pending, 'r').semutaReaction!;
|
||||
assert.equal(richese.canCommit, false);
|
||||
assert.match(richese.blocked ?? '', /free hand slot/);
|
||||
assert.equal(viewGame(pending, 'a').semutaReaction?.blocked, null);
|
||||
const before = JSON.stringify(pending);
|
||||
assert.throws(() => applyAction(pending, 'r', { type: 'semutaCommit', event }));
|
||||
assert.equal(JSON.stringify(pending), before);
|
||||
assert.equal(player(pending, 'r').hand.some(card => card.id === semuta.id), true);
|
||||
});
|
||||
|
||||
void test('a saved neutral reaction cannot be deleted to auto-retire the fresh discard', () => {
|
||||
const { g, hajr } = fixture();
|
||||
const pending = applyAction(g, 'a', { type: 'card', card: hajr.id });
|
||||
const corrupt = reload(pending);
|
||||
delete corrupt.pendingTreacheryDiscard!.reaction;
|
||||
const before = JSON.stringify(corrupt);
|
||||
assert.throws(() => viewGame(corrupt, 'r'));
|
||||
assert.throws(() => normalizeAutomaticGame(corrupt));
|
||||
assert.throws(() => applyAction(corrupt, 'a', { type: 'advanceBots' }));
|
||||
assert.equal(JSON.stringify(corrupt), before);
|
||||
});
|
||||
|
||||
void test('duplicate physical Semuta custody cannot survive projection or commit', () => {
|
||||
const { g, semuta, hajr } = fixture();
|
||||
const pending = applyAction(g, 'a', { type: 'card', card: hajr.id });
|
||||
const corrupt = reload(pending);
|
||||
corrupt.richeseCache!.push(structuredClone(semuta));
|
||||
const before = JSON.stringify(corrupt);
|
||||
assert.throws(() => viewGame(corrupt, 'r'));
|
||||
assert.throws(() => applyAction(corrupt, 'r', {
|
||||
type: 'semutaCommit', event: corrupt.pendingTreacheryDiscard!.batch.event,
|
||||
}));
|
||||
assert.equal(JSON.stringify(corrupt), before);
|
||||
});
|
||||
|
||||
void test('an impossible saved all-passed offer rejects instead of waiting forever', () => {
|
||||
const { g, hajr } = fixture();
|
||||
const pending = applyAction(g, 'a', { type: 'card', card: hajr.id });
|
||||
const corrupt = reload(pending);
|
||||
const reaction = corrupt.pendingTreacheryDiscard!.reaction;
|
||||
assert.equal(reaction?.stage, 'offer');
|
||||
if (reaction?.stage !== 'offer') return;
|
||||
reaction.passed = corrupt.players.map(player => player.id);
|
||||
const before = JSON.stringify(corrupt);
|
||||
assert.throws(() => viewGame(corrupt, 'r'));
|
||||
assert.throws(() => normalizeAutomaticGame(corrupt));
|
||||
assert.equal(JSON.stringify(corrupt), before);
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { SemutaReaction } from '../components/semuta-reaction';
|
||||
import type { GameView } from '../game/engine';
|
||||
|
||||
const first = { id: 'fresh-projectile-1', name: 'Projectile', kind: 'projectile' as const };
|
||||
const second = { id: 'fresh-projectile-2', name: 'Projectile', kind: 'projectile' as const };
|
||||
const event = 'discard-turn-7';
|
||||
|
||||
type Reaction = NonNullable<GameView['semutaReaction']>;
|
||||
|
||||
function view(reaction: Reaction | null): GameView {
|
||||
return { semutaReaction: reaction } as GameView;
|
||||
}
|
||||
|
||||
function offer(overrides: Partial<Reaction> = {}): Reaction {
|
||||
return {
|
||||
event,
|
||||
stage: 'offer',
|
||||
passed: false,
|
||||
canCommit: false,
|
||||
blocked: null,
|
||||
candidates: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function markup(reaction: Reaction | null, busy = false) {
|
||||
return renderToStaticMarkup(
|
||||
createElement(SemutaReaction, { game: view(reaction), act() {}, busy }),
|
||||
);
|
||||
}
|
||||
|
||||
void test('all seats see the same fresh-discard event without a card face before commitment', () => {
|
||||
const holder = markup(offer({ canCommit: true }));
|
||||
const rival = markup(offer());
|
||||
assert.match(holder, /Fresh Treachery discard/);
|
||||
assert.match(rival, /Fresh Treachery discard/);
|
||||
assert.match(holder, /discard-turn-7/);
|
||||
assert.match(rival, /discard-turn-7/);
|
||||
assert.match(holder, /Commit Semuta Drug/);
|
||||
assert.doesNotMatch(rival, /Commit Semuta Drug/);
|
||||
assert.match(holder, />Continue<\/button>/);
|
||||
assert.match(rival, />Continue<\/button>/);
|
||||
for (const html of [holder, rival]) {
|
||||
assert.doesNotMatch(html, /Projectile|fresh-projectile|Inspect card|Claim Projectile/);
|
||||
}
|
||||
assert.equal(markup(null), '');
|
||||
});
|
||||
|
||||
void test('only the claimant sees exact physical candidates after commitment', () => {
|
||||
const selecting = offer({ stage: 'select', candidates: [first, second] });
|
||||
const holder = markup(selecting);
|
||||
const rival = markup(offer({ stage: 'select' }));
|
||||
for (const id of [first.id, second.id]) {
|
||||
assert.match(holder, new RegExp(id));
|
||||
assert.doesNotMatch(rival, new RegExp(id));
|
||||
}
|
||||
assert.equal((holder.match(/Claim Projectile \(/g) ?? []).length, 2);
|
||||
assert.match(holder, /Inspect card/);
|
||||
assert.match(holder, /cannot be declined or canceled/);
|
||||
assert.doesNotMatch(holder, /Continue|Cancel|Decline/);
|
||||
assert.match(rival, /Waiting for the Semuta Drug claim to finish/);
|
||||
assert.doesNotMatch(rival, /Projectile|Inspect card|Commit Semuta Drug|>Continue</);
|
||||
});
|
||||
|
||||
void test('passed, blocked, busy and waiting seats hide or disable unavailable controls', () => {
|
||||
const passed = offer({ passed: true, canCommit: true });
|
||||
assert.match(markup(passed), /You continued/);
|
||||
assert.doesNotMatch(markup(passed), /Commit Semuta Drug|>Continue</);
|
||||
const blocked = offer({ blocked: 'Your hand has no free slot.' });
|
||||
assert.match(markup(blocked), /Your hand has no free slot/);
|
||||
assert.doesNotMatch(markup(offer()), /Your hand has no free slot/);
|
||||
assert.doesNotMatch(markup(blocked), /Commit Semuta Drug/);
|
||||
assert.match(markup(offer({ canCommit: true }), true), /disabled=""[^>]*>Commit Semuta Drug/);
|
||||
assert.match(markup(offer(), true), /disabled=""[^>]*>Continue/);
|
||||
assert.match(markup(offer({ stage: 'select', candidates: [first, second] }), true),
|
||||
/disabled=""[^>]*>Claim Projectile/);
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { startPrototypeRoom } from '../tools/prototype-room';
|
||||
import { botActions } from '../game/bots';
|
||||
import { viewGame, type Game, type Action } from '../game/engine';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import { unitStore } from './fixture-nexus-room-store';
|
||||
|
||||
const clock = { now: () => 10000, sleep: async () => {} };
|
||||
const seat = (g: Game, id: string) => g.players.find(p => p.id === id)!;
|
||||
function take(g: Game, identity: string) {
|
||||
for (const zone of [g.deck, g.discard, g.richeseCache!, ...g.players.map(p => p.hand)]) {
|
||||
const index = zone.findIndex(card => card.id === identity || card.effect === identity);
|
||||
if (index >= 0) return zone.splice(index, 1)[0];
|
||||
}
|
||||
throw new Error('The genuine setup lost a physical card.');
|
||||
}
|
||||
|
||||
void test('authenticated fresh discard survives restart and duplicate Semuta commits transfer one physical card', async () => {
|
||||
const f = unitStore();
|
||||
try {
|
||||
const created = await f.rooms.createRoom('Semuta QA', 'richese', false, ['choam']);
|
||||
const code = created.view.code;
|
||||
const second = await f.rooms.joinRoom(code, 'Atreides', 'atreides');
|
||||
const third = await f.rooms.joinRoom(code, 'Emperor', 'emperor');
|
||||
const auths = await Promise.all([created.token!, second.token!, third.token!]
|
||||
.map(token => f.restart().authenticate(code, token)));
|
||||
const act = async (index: number, action: Action) => {
|
||||
const before = await f.restart().readRoom(code);
|
||||
await f.restart().act(code, auths[index], before.version, action, clock);
|
||||
return f.restart().readRoom(code);
|
||||
};
|
||||
for (let i = 0; i < auths.length; i++) await act(i, { type: 'ready' });
|
||||
let game = await f.restart().readRoom(code);
|
||||
startPrototypeRoom(f.sqlite, code, game.version, 'semuta');
|
||||
game = await f.restart().readRoom(code);
|
||||
for (let step = 0; game.status === 'setup' && step < 80; step++) {
|
||||
let moved = false;
|
||||
for (let i = 0; i < auths.length; i++) {
|
||||
const view = viewGame(game, auths[i].playerId);
|
||||
view.players.find(p => p.id === view.me)!.bot = 'Medium';
|
||||
const choice = botActions(view)[0];
|
||||
if (choice) { game = await act(i, choice); moved = true; break; }
|
||||
}
|
||||
assert.ok(moved, 'genuine setup must retain a legal choice');
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
const ids = auths.map(auth => auth.playerId), originalSeats = f.sqlite.prepare('SELECT * FROM seats').all();
|
||||
const semuta = take(game, SEMUTA_DRUG_ID), hajr = take(game, 'hajr');
|
||||
game.deck.push(...game.players.flatMap(p => p.hand));
|
||||
for (const p of game.players) p.hand = [];
|
||||
seat(game, ids[0]).hand.push(semuta);
|
||||
seat(game, ids[1]).hand.push(hajr);
|
||||
Object.assign(game, { turn: 2, phase: 5, active: ids[1], storm: 18, ready: [],
|
||||
decision: null, response: null, phaseOpening: null, stormPending: null,
|
||||
movementRemaining: [ids[1], ids[0], ids[2]] });
|
||||
game.version++;
|
||||
assert.equal(f.sqlite.prepare('UPDATE rooms SET state=?,version=? WHERE code=? AND version=?')
|
||||
.run(JSON.stringify(game), game.version, code, game.version - 1).changes, 1);
|
||||
game = await act(1, { type: 'card', card: hajr.id });
|
||||
const event = game.pendingTreacheryDiscard?.batch.event;
|
||||
assert.ok(event);
|
||||
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
||||
const foreign = await f.restart().readSeatView(code, auths[2]);
|
||||
const owner = await f.restart().readSeatView(code, auths[0]);
|
||||
assert.equal(foreign.semutaReaction?.event, owner.semutaReaction?.event);
|
||||
assert.deepEqual(foreign.semutaReaction?.candidates, []);
|
||||
assert.deepEqual(owner.semutaReaction?.candidates, []);
|
||||
assert.equal(owner.semutaReaction?.canCommit, true);
|
||||
await f.restart().continueRoomAutomatic(code, clock);
|
||||
assert.equal((await f.restart().readRoom(code)).version, game.version);
|
||||
game = await act(1, { type: 'semutaPass', event });
|
||||
const version = game.version;
|
||||
let arrivals = 0; let release!: () => void;
|
||||
const barrier = new Promise<void>(resolve => { release = resolve; });
|
||||
f.hooks.beforeWrite = async () => { if (++arrivals === 2) release(); await barrier; };
|
||||
const results = await Promise.allSettled([
|
||||
f.rooms.act(code, auths[0], version, { type: 'semutaCommit', event }, clock),
|
||||
f.restart().act(code, auths[0], version, { type: 'semutaCommit', event }, clock),
|
||||
]);
|
||||
delete f.hooks.beforeWrite;
|
||||
assert.equal(arrivals, 2);
|
||||
assert.equal(results.filter(result => result.status === 'fulfilled').length, 1);
|
||||
assert.equal(results.filter(result => result.status === 'rejected').length, 1);
|
||||
const done = await f.restart().readRoom(code);
|
||||
assert.equal(done.version, version + 1);
|
||||
assert.equal(done.pendingTreacheryDiscard, null);
|
||||
assert.equal(seat(done, ids[0]).hand.filter(card => card.id === hajr.id).length, 1);
|
||||
assert.equal(done.discard.filter(card => card.id === semuta.id).length, 1);
|
||||
assert.equal(done.discard.some(card => card.id === hajr.id), false);
|
||||
assert.equal(done.hajr.filter(id => id === ids[1]).length, 1);
|
||||
const cards = [...done.deck, ...done.discard, ...done.richeseCache!, ...done.players.flatMap(p => p.hand)];
|
||||
assert.equal(new Set(cards.map(card => card.id)).size, cards.length);
|
||||
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), originalSeats);
|
||||
for (const auth of auths) assert.deepEqual(await f.restart().readSeatView(code, auth), viewGame(done, auth.playerId));
|
||||
} finally { f.sqlite.close(); }
|
||||
});
|
||||
Reference in new issue
Block a user