Allow Richese reveal during selected responses
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 07:58:48 +11:00
1 parent a1b2aacbac
commit 82d857b667
9 files changed
+518 -4

No files matched your search

@@ -0,0 +1,113 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { actFremenRevivalGrant as act, nextFremenRevivalGrantStep, fremenRevivalGrantClean as clean } from './fixture-fremen-revival-grant';
import { truthMovementInventory as inventory } from './fixture-truth-movement';
const owner = 'richese', fremen = 'fremen', ally = 'atreides', observer = 'emperor';
const player = (game: Game, id: string) => {
const found = game.players.find(row => row.id === id); assert.ok(found); return found;
};
function next(game: Game) {
// Richese pays for the real marker during its original first movement turn.
if (game.turn === 1 && game.phase === 5 && game.active === owner && clean(game)) {
const richese = player(game, owner);
if (!richese.shipped) {
const marker = richese.noField!.tokens.find(token => token.value === 3); assert.ok(marker);
return {actor:owner, action:{type:'ship', noField:marker.id, event:richese.noFieldEvent,
territory:'polar_sink', sector:0, allyPayment:0} satisfies Action};
}
return {actor:owner, action:{type:'endMovement'} satisfies Action};
}
return nextFremenRevivalGrantStep(game);
}
function reach(game: Game, until: (state: Game) => boolean): Game {
for (let count = 0; count < 2000; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The native game ended before its grant response.');
const step = next(game); assert.ok(step, `Supply native action at ${game.turn}/${game.phase}/${game.decision?.kind}`);
game = act(game, step.actor, step.action);
}
throw Error('The native Fremen grant response was not reached.');
}
/** Genuine Basic game: native starting components, real Richese paid shipment,
* Atreides invasion and Fremen battle losses, a subsequent Shai-Hulud and
* Fremen/Atreides alliance. Only original unopened cards and scalar lotteries
* are selected; no board force, wallet, alliance, response or casualty is staged. */
function nativeGrantSource() {
let game = createGame('RICHESEFREMENGRANT', newPlayer(fremen, 'Fremen', 'fremen'), false, ['choam']);
joinGame(game, newPlayer(ally, 'Atreides', 'atreides'));
joinGame(game, newPlayer('choam', 'CHOAM', 'choam'));
joinGame(game, newPlayer(owner, 'Richese', 'richese'));
joinGame(game, newPlayer(observer, 'Emperor', 'emperor'));
for (const seat of game.players) game = act(game, seat.id, {type:'ready'});
game = act(game, game.host, {type:'start'});
assert.equal(game.status, 'setup');
const ordinary = game.deck.filter(card => ['projectile','poison','shield','snooper'].includes(card.kind)).slice(0, 4);
const karama = game.deck.find(card => card.effect === 'karama');
assert.equal(ordinary.length, 4); assert.ok(karama);
for (const [position, card] of [...ordinary, karama].entries()) {
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
}
game = reach(game, state => state.status === 'playing');
assert.ok(player(game, observer).hand.some(card => card.id === karama.id));
for (const position of [0,1]) {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card &&
!card.discovery && card.territory !== 'sietch_tabr');
assert.ok(from >= position);
[game.spiceDeck[position], game.spiceDeck[from]] = [game.spiceDeck[from], game.spiceDeck[position]];
}
game = reach(game, state => state.turn === 1 && state.phase === 5 && state.active === ally && clean(state));
game = act(game, ally, {type:'ship', territory:'sietch_tabr', sector:14, amount:6, elite:0, allyPayment:0});
game = reach(game, state => state.turn === 1 && state.phase === 6 && clean(state) && !!state.active);
const choice = viewGame(game, game.active!).battleChoices.find(row => row.territory === 'sietch_tabr'); assert.ok(choice);
game = act(game, choice.chooser, {type:'chooseBattle', territory:'sietch_tabr',
target:choice.attacker === choice.chooser ? choice.defender : choice.attacker});
for (const actor of [fremen, ally]) {
game = reach(game, state => !!state.battle && clean(state) && !state.battle.preparation && state.battle.preLeader?.closed !== false);
const leaders = player(game, actor).leaders.filter(leader => !leader.dead && !leader.usedAt)
.sort((a,b) => a.strength - b.strength || a.id.localeCompare(b.id));
const leader = actor === fremen ? leaders.at(-1) : leaders[0]; assert.ok(leader);
game = act(game, actor, {type:'battlePlan', leader:leader.id, dial:actor === fremen ? 6 : 0,
support:0, weapon:null, defense:null});
}
game = reach(game, state => !state.battle && clean(state));
assert.equal(player(game, ally).tanks, 6);
const marker = player(game, owner).noField?.deployed; assert.ok(marker);
const event = player(game, owner).noFieldEvent; assert.ok(event);
game = reach(game, state => state.turn === 2 && state.phase === 1 && clean(state) &&
!state.spiceSequence && !state.spiceResolution && !state.spiceWindow && !state.nexus && state.ready.length === 0);
const worm = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper);
assert.ok(worm >= 0); game.spiceDeck.unshift(game.spiceDeck.splice(worm,1)[0]);
game = reach(game, state => !!state.nexus && !state.spiceWindow && !state.spiceResolution && clean(state));
game = act(game, fremen, {type:'alliance', target:ally});
game = act(game, ally, {type:'alliance', target:fremen});
assert.equal(player(game, fremen).ally, ally); assert.equal(player(game, ally).ally, fremen);
game = reach(game, state => state.turn === 2 && state.phase === 4 && clean(state));
game = act(game, fremen, {type:'grantRevival'});
assert.equal(game.response?.kind, 'fremenRevivalGrant');
assert.ok(player(game, observer).hand.some(card => card.id === karama.id));
return {game, token:marker.tokenId, event, cards:inventory(game)};
}
void test('voluntary No-Field reveal preserves the real Fremen ally grant response and revival continuation', () => {
const {game,token,event,cards} = nativeGrantSource(), response = structuredClone(game.response),
reserves = player(game, owner).reserves, field = player(game, owner).forces['polar_sink:0'] ?? 0,
tanks = player(game, ally).tanks;
assert.equal(viewGame(game, owner).richeseNoField?.canReveal, true);
const after = act(game, owner, {type:'revealNoField', token, event});
assert.deepEqual(after.response, response);
assert.equal(player(after, owner).noField?.deployed, null);
assert.equal(player(after, owner).reserves, reserves - 3);
assert.equal(player(after, owner).forces['polar_sink:0'], field + 3);
assert.deepEqual(inventory(after), cards);
const control = act(game, observer, {type:'passResponse'});
const continued = act(after, observer, {type:'passResponse'});
assert.notEqual(continued.response?.kind, 'fremenRevivalGrant');
assert.deepEqual(continued.freeRevival, control.freeRevival);
assert.equal(player(continued, ally).tanks, tanks);
assert.equal(player(continued, owner).forces['polar_sink:0'], field + 3);
assert.deepEqual(inventory(continued), cards);
});
@@ -58,6 +58,87 @@ void test('a clean Ix phase opening remains admitted before or after its owner p
game.decision = null; game.amalWindowOpen = false; assert.equal(supported(game), false);
});
void test('supported native responses admit the reveal without changing their original source or passes', () => {
const charity = source(); charity.phase = 2;
charity.response = {kind:'choamCharity', owner:'c', passed:['e']};
const auction = source(); auction.players.push(newPlayer('a','Atreides','atreides'));
auction.auction = {cards:[{id:'lot', name:'Shield', kind:'shield'}], index:0, bid:0,
bidder:null, active:'a', passed:[], opener:0};
auction.response = {kind:'atreidesAuction', owner:'a', passed:['e']};
const declaration = source();
declaration.richeseCache = [];
declaration.richeseBidding = {owner:'r', event:'round', turn:declaration.turn,
stage:'declaration', position:'first', normalCount:1, normalCountBeforeCache:1,
blackMarketSold:false, cacheCanceled:false};
declaration.response = {kind:'richeseAuction',owner:'r',passed:['e']};
const grant = source(); grant.phase = 4;
grant.players.push(newPlayer('f','Fremen','fremen'));
grant.players.find(p => p.id === 'f')!.ally = 'r'; grant.players.find(p => p.id === 'r')!.ally = 'f';
grant.freeRevival = [];
grant.response = {kind:'fremenRevivalGrant', owner:'f', recipient:'r', passed:['e'],
fremenGrant:{turn:grant.turn, owner:'f', recipient:'r'}};
for (const game of [charity, auction, declaration, grant]) {
const before = structuredClone(game);
assert.equal(supported(game), true, game.response!.kind);
assert.deepEqual(game, before);
}
});
void test('response admission requires the original phase, owner, source and no nested interaction', () => {
const charity = source(); charity.phase = 2; charity.response = {kind:'choamCharity',owner:'c',passed:[]};
for (const patch of [
{phase:3}, {decision:{kind:'auctionPayment',player:'e'}}, {phaseOpening:{passed:[],initialize:true}},
{pendingShipment:{}}, {response:{kind:'choamCharity',owner:'e',passed:[]}},
{response:{kind:'choamCharity',owner:'c',passed:['unknown']}},
{response:{kind:'choamCharity',owner:'c',passed:['e','e']}},
{response:{kind:'choamCharity',owner:'c',passed:[],intent:'extra'}},
{choamCharity:{turn:2,canceled:false}},
]) assert.equal(supported(Object.assign(structuredClone(charity), patch)), false, JSON.stringify(patch));
const auction = source(); auction.players.push(newPlayer('a','Atreides','atreides'));
auction.auction = {cards:[{id:'lot',name:'Shield',kind:'shield'}], index:0, bid:0,
bidder:null, active:'a', passed:[], opener:0};
auction.response = {kind:'atreidesAuction',owner:'a',passed:[]};
const missingLot = structuredClone(auction); missingLot.auction!.index = 1;
assert.equal(supported(missingLot), false);
const wrongOwner = structuredClone(auction); wrongOwner.response!.owner = 'e';
assert.equal(supported(wrongOwner), false);
const unsupportedLot = structuredClone(auction);
unsupportedLot.richeseAuction = {} as NonNullable<Game['richeseAuction']>;
assert.equal(supported(unsupportedLot), false);
const otherResponse = structuredClone(auction); otherResponse.response!.kind = 'richeseAuction';
assert.equal(supported(otherResponse), false);
const declaration = source(); declaration.richeseCache = [];
declaration.richeseBidding = {owner:'r',event:'round',turn:declaration.turn,stage:'declaration',
position:'first',normalCount:1,normalCountBeforeCache:1,blackMarketSold:false,cacheCanceled:false};
declaration.response = {kind:'richeseAuction',owner:'r',passed:[]};
for (const patch of [
{phase:4}, {richeseCache:undefined}, {auction:auction.auction},
{richeseBidding:{...declaration.richeseBidding,turn:1}},
{richeseBidding:{...declaration.richeseBidding,owner:'e'}},
{richeseBidding:{...declaration.richeseBidding,stage:'lot'}},
{richeseBidding:{...declaration.richeseBidding,position:undefined}},
{richeseBidding:{...declaration.richeseBidding,normalCount:-1}},
{richeseBidding:{...declaration.richeseBidding,normalCountBeforeCache:3}},
{richeseBidding:{...declaration.richeseBidding,cacheCanceled:true}},
{pendingRevival:{}},
]) assert.equal(supported(Object.assign(structuredClone(declaration), patch)), false, JSON.stringify(patch));
const grant = source(); grant.phase = 4; grant.players.push(newPlayer('f','Fremen','fremen'));
grant.players.find(p => p.id === 'f')!.ally = 'r'; grant.players.find(p => p.id === 'r')!.ally = 'f';
grant.freeRevival = [];
grant.response = {kind:'fremenRevivalGrant',owner:'f',recipient:'r',passed:[],
fremenGrant:{turn:grant.turn,owner:'f',recipient:'r'}};
const stale = structuredClone(grant); stale.response!.fremenGrant!.turn--;
assert.equal(supported(stale), false);
const noAlly = structuredClone(grant); noAlly.players.find(p => p.id === 'r')!.ally = null;
assert.equal(supported(noAlly), false);
const wrongRecipient = structuredClone(grant); wrongRecipient.response!.recipient = 'e';
assert.equal(supported(wrongRecipient), false);
const unrelatedChild = structuredClone(grant); unrelatedChild.pendingRevival = {} as NonNullable<Game['pendingRevival']>;
assert.equal(supported(unrelatedChild), false);
});
void test('a legacy table without public Truthtrance history keeps its earlier interruption behavior', () => {
const game = parent('auctionPayment');
delete game.truthHistory;
@@ -0,0 +1,180 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { actTruthMovementNativeEarly as act, nextTruthMovementNativeEarlyStep } from './fixture-truth-movement-native-early';
import { truthMovementInventory as inventory } from './fixture-truth-movement';
const owner = 'richese', choam = 'choam', observer = 'emperor';
const player = (game: Game, id: string) => {
const found = game.players.find(row => row.id === id); assert.ok(found); return found;
};
const clean = (game: Game) => !game.response && !game.decision && !game.phaseOpening &&
!game.truthtrance && !game.pendingTreacheryDiscard;
function next(game: Game) {
if (game.phase === 5 && game.active === observer && clean(game))
return { actor: observer, action: { type:'endMovement' } satisfies Action };
return nextTruthMovementNativeEarlyStep(game, {
position:'first', method:'onceAround', direction:'clockwise', keepUnbid:false,
});
}
function reach(game: Game, until: (state: Game) => boolean): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The native game ended before its response.');
const step = next(game); assert.ok(step, `Supply native action at ${game.turn}/${game.phase}/${game.decision?.kind}`);
game = act(game, step.actor, step.action);
}
throw Error('The native response was not reached.');
}
/** Genuine Basic setup with CHOAM and Ix decks, a paid first-turn No-Field
* shipment and the original next-turn Charity response. Only unopened starting
* deal and Spice card positions are selected; the physical Karama is dealt to
* Emperor and held through the intervening native turns. */
function nativeCharitySource(withAtreides = false) {
let game = createGame('RICHESECHARITY', newPlayer(owner, 'Richese', 'richese'), false, ['choam','ix']);
joinGame(game, newPlayer(choam, 'CHOAM', 'choam'));
if (withAtreides) joinGame(game, newPlayer('atreides', 'Atreides', 'atreides'));
joinGame(game, newPlayer(observer, 'Emperor', 'emperor'));
for (const seat of game.players) game = act(game, seat.id, {type:'ready'});
game = act(game, game.host, {type:'start'});
assert.equal(game.status, 'setup');
const ordinaryCount = withAtreides ? 3 : 2;
const ordinary = game.deck.filter(card => ['projectile','poison','shield','snooper'].includes(card.kind)).slice(0, ordinaryCount);
const karama = game.deck.find(card => card.effect === 'karama');
assert.equal(ordinary.length, ordinaryCount); assert.ok(karama);
for (const [position, card] of [...ordinary, karama].entries()) {
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
}
game = reach(game, state => state.status === 'playing');
assert.ok(player(game, observer).hand.some(card => card.id === karama.id));
for (const position of [0, 1]) {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
assert.ok(from >= position);
[game.spiceDeck[position], game.spiceDeck[from]] = [game.spiceDeck[from], game.spiceDeck[position]];
}
game = reach(game, state => state.turn === 1 && state.phase === 5 && state.active === owner && clean(state));
const token = player(game, owner).noField!.tokens.find(item => item.value === 3); assert.ok(token);
const shipmentEvent = player(game, owner).noFieldEvent; assert.ok(shipmentEvent);
game = act(game, owner, {type:'ship', noField:token.id, event:shipmentEvent,
territory:'polar_sink', sector:0, allyPayment:0});
game = reach(game, clean);
assert.equal(player(game, owner).noField?.deployed?.tokenId, token.id);
game = act(game, owner, {type:'endMovement'});
game = reach(game, state => state.turn === 2 && state.phase === 2 && state.response?.kind === 'choamCharity');
assert.equal(game.response?.owner, choam);
assert.ok(player(game, observer).hand.some(card => card.id === karama.id));
const event = player(game, owner).noFieldEvent; assert.ok(event);
return {game, token:token.id, event, karama:karama.id, cards:inventory(game)};
}
let saved: ReturnType<typeof nativeCharitySource> | undefined;
const source = () => saved ??= nativeCharitySource();
let savedAtreides: ReturnType<typeof nativeCharitySource> | undefined;
const atreidesSource = () => savedAtreides ??= nativeCharitySource(true);
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
void test('Richese reveals during the real CHOAM Charity response while its original cancel opportunity and continuation remain', () => {
const {token,event,karama,cards} = source();
let before = structuredClone(source().game);
before = act(before, owner, {type:'passResponse'});
assert.equal(before.response?.kind, 'choamCharity');
assert.ok(before.response.passed.includes(owner));
const response = structuredClone(before.response), cash = player(before, choam).spice,
reserves = player(before, owner).reserves, field = player(before, owner).forces['polar_sink:0'] ?? 0;
assert.equal(viewGame(before, owner).richeseNoField?.canReveal, true);
reject(before, observer, {type:'revealNoField', token, event});
reject(before, owner, {type:'revealNoField', token, event:'stale'});
reject(before, owner, {type:'revealNoField', token:'stale', event});
let after = act(before, owner, {type:'revealNoField', token, event});
assert.deepEqual(after.response, response);
assert.equal(after.turn, before.turn); assert.equal(after.phase, before.phase);
assert.equal(player(after, owner).noField?.deployed, null);
assert.equal(player(after, owner).noField?.lastShipped, token);
assert.notEqual(player(after, owner).noFieldEvent, event);
assert.equal(player(after, owner).reserves, reserves - 3);
assert.equal(player(after, owner).forces['polar_sink:0'], field + 3);
assert.equal(player(after, choam).spice, cash);
assert.ok(player(after, observer).hand.some(card => card.id === karama));
assert.deepEqual(inventory(after), cards);
reject(after, owner, {type:'revealNoField', token, event});
after = act(after, observer, {type:'passResponse'});
assert.notEqual(after.response?.kind, 'choamCharity');
assert.equal(player(after, choam).spice, cash + 2 * before.players.length);
assert.equal(player(after, owner).forces['polar_sink:0'], field + 3);
assert.deepEqual(inventory(after), cards);
});
void test('all minimal bot profiles can continue the preserved CHOAM Charity response', () => {
const {game, token, event} = source();
const revealed = act(structuredClone(game), owner, {type:'revealNoField', token, event});
for (const difficulty of DIFFICULTIES) {
const automated = structuredClone(revealed);
player(automated, observer).bot = difficulty;
const choice = botActions(viewGame(automated, observer)).find(action =>
action.type === 'passResponse' || action.type === 'card' && action.mode === 'cancel');
assert.ok(choice, `${difficulty} needs a legal response`);
const continued = applyAction(automated, observer, choice);
assert.ok(continued.response?.kind !== 'choamCharity' ||
continued.response.passed.includes(observer) || continued.pendingKarama || continued.pendingKull ||
continued.pendingTreacheryDiscard,
`${difficulty} must advance the original response: ${JSON.stringify({choice, response:continued.response,
decision:continued.decision, discard:!!continued.pendingTreacheryDiscard})}`);
assert.equal(player(continued, owner).forces['polar_sink:0'], 3);
}
});
void test('Richese reveals during the real Atreides normal-auction inspection response and preserves its lot', () => {
const {token,event,karama,cards} = atreidesSource();
const before = reach(structuredClone(atreidesSource().game), state => state.turn === 2 && state.phase === 3 &&
state.response?.kind === 'atreidesAuction' && !!state.auction && !state.richeseAuction);
assert.equal(before.response?.owner, 'atreides');
assert.ok(player(before, observer).hand.some(card => card.id === karama));
const response = structuredClone(before.response), lot = structuredClone(before.auction),
reserves = player(before, owner).reserves, field = player(before, owner).forces['polar_sink:0'] ?? 0;
assert.equal(viewGame(before, owner).richeseNoField?.canReveal, true);
const after = act(before, owner, {type:'revealNoField', token, event});
assert.deepEqual(after.response, response); assert.deepEqual(after.auction, lot);
assert.equal(player(after, owner).noField?.deployed, null);
assert.equal(player(after, owner).reserves, reserves - 3);
assert.equal(player(after, owner).forces['polar_sink:0'], field + 3);
assert.deepEqual(inventory(after), cards);
const control = act(before, observer, {type:'passResponse'});
const continued = act(after, observer, {type:'passResponse'});
assert.notEqual(continued.response?.kind, 'atreidesAuction');
assert.deepEqual(continued.auction, control.auction);
assert.equal(player(continued, owner).forces['polar_sink:0'], field + 3);
assert.deepEqual(inventory(continued), cards);
});
void test('Richese reveals during the real cache-auction prevention response without replaying its declaration', () => {
const {token,event,karama,cards} = source();
const before = reach(structuredClone(source().game), state => state.turn === 2 && state.phase === 3 &&
state.response?.kind === 'richeseAuction');
assert.equal(before.response?.owner, owner);
assert.ok(player(before, observer).hand.some(card => card.id === karama));
const response = structuredClone(before.response), round = structuredClone(before.richeseBidding),
reserves = player(before, owner).reserves, field = player(before, owner).forces['polar_sink:0'] ?? 0;
assert.equal(viewGame(before, owner).richeseNoField?.canReveal, true);
const after = act(before, owner, {type:'revealNoField', token, event});
assert.deepEqual(after.response, response); assert.deepEqual(after.richeseBidding, round);
assert.equal(player(after, owner).noField?.deployed, null);
assert.equal(player(after, owner).reserves, reserves - 3);
assert.equal(player(after, owner).forces['polar_sink:0'], field + 3);
assert.deepEqual(inventory(after), cards);
const control = act(before, observer, {type:'passResponse'});
const continued = act(after, observer, {type:'passResponse'});
assert.notEqual(continued.response?.kind, 'richeseAuction');
assert.ok(continued.richeseBidding?.event);
assert.deepEqual({...continued.richeseBidding, event:undefined}, {...control.richeseBidding, event:undefined});
assert.deepEqual(continued.richeseAuction, control.richeseAuction);
assert.deepEqual({...continued.decision, event:undefined}, {...control.decision, event:undefined});
assert.equal(player(continued, owner).forces['polar_sink:0'], field + 3);
assert.deepEqual(inventory(continued), cards);
});
+58
View File
@@ -1,6 +1,8 @@
// @dune-suite integration
import test from 'node:test';
import assert from 'node:assert/strict';
import { Agent as HttpAgent, request as httpRequest } from 'node:http';
import { Agent as HttpsAgent, request as httpsRequest } from 'node:https';
import type { GameView } from '../game/engine';
import type { TalkPage } from '../lib/table-talk';
const base = process.env.DUNE_TEST_URL ?? 'http://localhost:3000';
@@ -230,3 +232,59 @@ void test('discussion HTTP retries save once and seat recovery inherits conversa
{ messages: [], before: null, muted: false },
);
});
/** Reuse one connection when the server permits it; a server that explicitly
* closes a connection may instead provide a fresh one. Never retry a POST. */
void test('rejected discussion posts leave the next recovered-seat request usable', async () => {
const f = await fixture();
const endpoint = f.path + '/messages';
const message = { id: crypto.randomUUID(), recipientId: f.b.data.me, text: 'Keep-alive recovery witness.' };
assert.equal((await request(endpoint, message, f.a.cookie)).status, 200);
const recoverySecret = 'b8'.repeat(32);
assert.equal((await request(f.path + '/control', {
type: 'setRecoveryKey', version: f.version, recoverySecret,
}, f.a.cookie)).status, 200);
const recovered = await request(f.path + '/control', {
type: 'recoverSeat', playerId: f.a.data.me, recoverySecret,
operationId: crypto.randomUUID(),
newSessionToken: crypto.randomUUID().replaceAll('-', '') + crypto.randomUUID().replaceAll('-', ''),
});
assert.equal(recovered.status, 200);
assert.ok(recovered.cookie);
const address = new URL(base);
const agent = address.protocol === 'https:'
? new HttpsAgent({ keepAlive: true, maxSockets: 1 })
: new HttpAgent({ keepAlive: true, maxSockets: 1 });
async function onConnection(path: string, cookie: string, body?: unknown, origin?: string) {
const url = new URL(path, base);
const payload = body === undefined ? undefined : JSON.stringify(body);
const send = url.protocol === 'https:' ? httpsRequest : httpRequest;
return await new Promise<{status: number; body: string}>((resolve, reject) => {
const req = send(url, {
agent, method: payload === undefined ? 'GET' : 'POST', signal: AbortSignal.timeout(15_000),
headers: { cookie, ...(origin ? { origin } : {}),
...(payload === undefined ? {} : { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) }) },
}, response => {
const chunks: Buffer[] = [];
response.on('data', chunk => chunks.push(Buffer.from(chunk)));
response.on('error', reject);
response.on('end', () => resolve({status: response.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8')}));
});
req.on('error', reject);
req.end(payload);
});
}
try {
const restoredPath = endpoint + `?recipient=${f.b.data.me}`;
assert.equal((await onConnection(endpoint, f.a.cookie!, message)).status, 409);
const restored = await onConnection(restoredPath, recovered.cookie);
assert.equal(restored.status, 200);
assert.deepEqual((JSON.parse(restored.body) as TalkPage).messages.map(row => row.id), [message.id]);
assert.equal((await onConnection(endpoint, recovered.cookie, message, 'https://foreign.invalid')).status, 403);
assert.equal((await onConnection(restoredPath, recovered.cookie)).status, 200);
assert.equal((await onConnection(restoredPath, f.a.cookie!)).status, 409);
} finally {
agent.destroy();
}
});