Recognize group allies in bribes and deals
Verify and publish checkpoint container / container (push) Canceled after 0s
Verify and publish checkpoint container / container (push) Canceled after 0s
This commit is contained in:
1 parent
2fe97ddd1f
commit
6e26ceb216
12 files changed
+318
-29
No files matched your search
@@ -0,0 +1,158 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { PublicDealList, PublicDeals } from '../components/public-deals';
|
||||
import { bribeAction } from '../game/bribe-options';
|
||||
import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance';
|
||||
import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
|
||||
const act = (game: Game, id: string, action: Action) => applyAction(game, id, action);
|
||||
const seat = (game: Game, id: string) => game.players.find(player => player.id === id)!;
|
||||
|
||||
/** The group mode has no lobby entry. Stage a Basic Nexus and wallets, then
|
||||
* form the pair and unanimous group using the actual engine actions. */
|
||||
function fixture(): Game {
|
||||
const game = createGame('GROUPDEALS', newPlayer('a', 'Atreides', 'atreides'), false);
|
||||
game.players.push(newPlayer('e', 'Emperor', 'emperor'),
|
||||
newPlayer('f', 'Fremen', 'fremen'), newPlayer('h', 'Harkonnen', 'harkonnen'));
|
||||
Object.assign(game, {status:'playing', phase:1, turn:2, nexus:true, order:['a','e','f','h'],
|
||||
ready:[], response:null, decision:null, phaseOpening:null});
|
||||
game.coalitionState = emptyCoalitionState();
|
||||
for (const player of game.players) player.spice = 10;
|
||||
return game;
|
||||
}
|
||||
function pair(game: Game): Game {
|
||||
game = act(game,'a',{type:'alliance',target:'e'});
|
||||
return act(game,'e',{type:'alliance',target:'a'});
|
||||
}
|
||||
function group(game: Game): Game {
|
||||
game = pair(game);
|
||||
const original = coalitionOf(game.players, game.coalitionState, 'a'); assert.ok(original);
|
||||
game = act(game,'a',{type:'coalitionPropose',groupId:original.id,entrant:'f',
|
||||
expectedRevision:game.coalitionState!.revision});
|
||||
for (const id of ['e','f']) {
|
||||
const proposal = game.coalitionState!.proposal!;
|
||||
game = act(game,id,{type:'coalitionAnswer',event:proposal.event,nexus:proposal.nexus,
|
||||
turn:proposal.turn,accept:true,expectedRevision:game.coalitionState!.revision});
|
||||
}
|
||||
assert.deepEqual(coalitionOf(game.players, game.coalitionState, 'a')!.members, ['a','e','f']);
|
||||
return game;
|
||||
}
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => act(game,actor,action));
|
||||
assert.deepEqual(game,before);
|
||||
}
|
||||
function offer(game: Game, actor: string, target: string) {
|
||||
const next = act(game,actor,{type:'dealOffer',target,terms:'Share information this turn.'});
|
||||
const deal = next.deals!.at(-1)!;
|
||||
return {game:next,id:deal.id};
|
||||
}
|
||||
|
||||
void test('group bribe projections and native payments exclude both allies, but retain outsiders', () => {
|
||||
let game = group(fixture());
|
||||
Object.assign(game,{phase:3,nexus:false,active:'h'});
|
||||
for (const actor of ['a','e','f']) {
|
||||
const allies = ['a','e','f'].filter(id => id !== actor);
|
||||
const options = viewGame(game,actor).bribeOptions;
|
||||
assert.equal(options.blocked,null);
|
||||
assert.deepEqual(options.targets.map(target => target.id),['h']);
|
||||
for (const target of allies) {
|
||||
assert.equal(bribeAction(options,target,1),null);
|
||||
reject(game,actor,{type:'bribe',target,amount:1});
|
||||
}
|
||||
}
|
||||
assert.deepEqual(viewGame(game,'h').bribeOptions.targets.map(target => target.id),['a','e','f']);
|
||||
game = act(game,'a',{type:'bribe',target:'h',amount:3});
|
||||
assert.equal(seat(game,'a').spice,7);
|
||||
assert.equal(seat(game,'h').spice,10);
|
||||
assert.equal(seat(game,'h').bribes,3);
|
||||
});
|
||||
|
||||
void test('pair bribe rules and outsider group payments remain available', () => {
|
||||
let game = pair(fixture());
|
||||
Object.assign(game,{phase:3,nexus:false,active:'h'});
|
||||
assert.deepEqual(viewGame(game,'a').bribeOptions.targets.map(target => target.id),['f','h']);
|
||||
reject(game,'a',{type:'bribe',target:'e',amount:1});
|
||||
game = act(game,'a',{type:'bribe',target:'f',amount:2});
|
||||
assert.equal(seat(game,'a').spice,8);
|
||||
assert.equal(seat(game,'f').bribes,2);
|
||||
});
|
||||
|
||||
void test('group public offers require every pair of participants to be nonallied', () => {
|
||||
const game = group(fixture());
|
||||
for (const target of ['e','f'])
|
||||
reject(game,'a',{type:'dealOffer',target,terms:'Share information.'});
|
||||
reject(game,'h',{type:'dealOffer',targets:['a','e'],terms:'All three share information.'});
|
||||
reject(game,'h',{type:'dealOffer',targets:['e','f'],terms:'All three share information.'});
|
||||
const offered = offer(game,'a','h');
|
||||
const accepted = act(offered.game,'h',{type:'dealRespond',deal:offered.id,accept:true});
|
||||
assert.equal(accepted.deals!.at(-1)!.status,'active');
|
||||
});
|
||||
|
||||
void test('an unanswered multiparty offer cannot be accepted after its parties form a group, but can be declined', () => {
|
||||
let game = fixture();
|
||||
game = act(game,'h',{type:'dealOffer',targets:['a','e'],terms:'Share information.'});
|
||||
const deal = game.deals!.at(-1)!.id;
|
||||
game = group(game);
|
||||
reject(game,'a',{type:'dealRespond',deal,accept:true});
|
||||
const declined = act(game,'a',{type:'dealRespond',deal,accept:false});
|
||||
assert.equal(declined.deals!.at(-1)!.status,'declined');
|
||||
});
|
||||
|
||||
void test('an already active agreement remains releasable after group formation', () => {
|
||||
const offered = offer(fixture(),'a','e');
|
||||
let game = act(offered.game,'e',{type:'dealRespond',deal:offered.id,accept:true});
|
||||
game = group(game);
|
||||
assert.equal(game.deals!.at(-1)!.status,'active');
|
||||
game = act(game,'a',{type:'dealRelease',deal:offered.id,agree:true});
|
||||
game = act(game,'e',{type:'dealRelease',deal:offered.id,agree:true});
|
||||
assert.equal(game.deals!.at(-1)!.status,'released');
|
||||
});
|
||||
|
||||
void test('a deferred payment becomes unavailable between new group allies while another outsider payment remains possible', () => {
|
||||
let game = fixture();
|
||||
game = act(game,'a',{type:'dealOffer',target:'e',terms:'Pay for information.',
|
||||
payment:{kind:'deferredBribe',payer:'a',recipient:'e',amount:2,dueTurn:3}});
|
||||
const alliedDeal = game.deals!.at(-1)!.id;
|
||||
game = act(game,'e',{type:'dealRespond',deal:alliedDeal,accept:true});
|
||||
game = act(game,'a',{type:'dealOffer',target:'h',terms:'Pay for information.',
|
||||
payment:{kind:'deferredBribe',payer:'a',recipient:'h',amount:3,dueTurn:3}});
|
||||
const outsideDeal = game.deals!.at(-1)!.id;
|
||||
game = act(game,'h',{type:'dealRespond',deal:outsideDeal,accept:true});
|
||||
game = group(game);
|
||||
Object.assign(game,{phase:3,nexus:false,active:'h'});
|
||||
assert.equal(game.deals!.find(deal => deal.id === alliedDeal)!.paymentStatus,'pending');
|
||||
assert.ok(viewGame(game,'a').deferredDealPaymentOptions[alliedDeal]?.blocked);
|
||||
assert.equal(viewGame(game,'a').deferredDealPaymentOptions[outsideDeal]?.blocked,null);
|
||||
reject(game,'a',{type:'dealPay',deal:alliedDeal});
|
||||
assert.equal(seat(game,'a').spice,10);
|
||||
assert.equal(seat(game,'e').bribes,0);
|
||||
game = act(game,'a',{type:'dealPay',deal:outsideDeal});
|
||||
assert.equal(seat(game,'a').spice,7);
|
||||
assert.equal(seat(game,'h').bribes,3);
|
||||
assert.equal(game.deals!.find(deal => deal.id === outsideDeal)!.paymentStatus,'paid');
|
||||
assert.equal(game.deals!.find(deal => deal.id === alliedDeal)!.paymentStatus,'pending');
|
||||
game = act(game,'a',{type:'dealRelease',deal:alliedDeal,agree:true});
|
||||
game = act(game,'e',{type:'dealRelease',deal:alliedDeal,agree:true});
|
||||
const released = game.deals!.find(deal => deal.id === alliedDeal)!;
|
||||
assert.equal(released.status,'released');
|
||||
assert.equal(released.paymentStatus,'released');
|
||||
});
|
||||
|
||||
void test('public deal controls show group allies as unavailable and still allow decline', () => {
|
||||
let game = fixture();
|
||||
game = act(game,'h',{type:'dealOffer',targets:['a','e'],terms:'Share information.'});
|
||||
game = group(game);
|
||||
const own = viewGame(game,'a');
|
||||
const list = renderToStaticMarkup(createElement(PublicDealList,{game:own,disabled:false,act() {}}));
|
||||
assert.match(list,/Acceptance is unavailable while any participants are allied/);
|
||||
assert.match(list,/<button\b[^>]*disabled=""[^>]*>Accept deal/);
|
||||
const decline = list.match(/<button\b[^>]*>Decline deal<\/button>/)?.[0];
|
||||
assert.ok(decline);
|
||||
assert.doesNotMatch(decline, /\sdisabled=""/);
|
||||
const form = renderToStaticMarkup(createElement(PublicDeals,{game:own,busy:false,act() {}}));
|
||||
assert.match(form,/Emperor[^<]* · Emperor \(allied with a selected party\)/);
|
||||
assert.match(form,/Fremen[^<]* · Fremen \(allied with a selected party\)/);
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { BribePromiseGuideButton } from '../components/bribe-promises';
|
||||
import { mixedBribeInputError } from '../components/mixed-bribe-fields';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance';
|
||||
import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
|
||||
const act = (game:Game, id:string, action:Action) => applyAction(game,id,action);
|
||||
|
||||
/** Group entry is staged at a Basic Nexus. Pair/group formation, physical
|
||||
* Truthtrance transfer, declaration, priority and questions use native actions. */
|
||||
function source() {
|
||||
let game = createGame('GROUPTRUTHBRIBE',newPlayer('a','Atreides','atreides'),false);
|
||||
game.players.push(newPlayer('e','Emperor','emperor'),newPlayer('f','Fremen','fremen'),
|
||||
newPlayer('h','Harkonnen','harkonnen'));
|
||||
Object.assign(game,{status:'playing',phase:1,turn:2,nexus:true,order:['a','e','f','h'],
|
||||
ready:[],response:null,decision:null,phaseOpening:null,deck:baseDeck()});
|
||||
game.coalitionState = emptyCoalitionState();
|
||||
game = act(game,'a',{type:'alliance',target:'e'});
|
||||
game = act(game,'e',{type:'alliance',target:'a'});
|
||||
const pair = coalitionOf(game.players,game.coalitionState,'a'); assert.ok(pair);
|
||||
game = act(game,'a',{type:'coalitionPropose',groupId:pair.id,entrant:'f',expectedRevision:game.coalitionState!.revision});
|
||||
for (const id of ['e','f']) {
|
||||
const invitation = game.coalitionState!.proposal!;
|
||||
game = act(game,id,{type:'coalitionAnswer',event:invitation.event,nexus:invitation.nexus,
|
||||
turn:invitation.turn,accept:true,expectedRevision:game.coalitionState!.revision});
|
||||
}
|
||||
Object.assign(game,{phase:3,nexus:false,active:'h',ready:[]});
|
||||
for (const player of game.players) { player.spice = 10; player.hand = []; }
|
||||
const index = game.deck.findIndex(card => card.effect === 'truthtrance'); assert.ok(index >= 0);
|
||||
const [truth] = game.deck.splice(index,1);
|
||||
game.players.find(player => player.id === 'h')!.hand.push(truth);
|
||||
game = act(game,'h',{type:'card',card:truth.id});
|
||||
for (let count=0; game.truthtrance?.stage === 'priority' && count < game.players.length; count++) {
|
||||
const next = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(next);
|
||||
game = act(game,next.id,{type:'truthPass'});
|
||||
}
|
||||
assert.equal(game.truthtrance?.stage,'ask');
|
||||
return game;
|
||||
}
|
||||
const question = (recipient:string) => ({type:'truthAsk',question:{kind:'bribe',target:'a',turn:2,recipient,minimum:3}} as Action);
|
||||
|
||||
void test('group allies cannot be named in a native Truthtrance bribe question, but an outsider can', () => {
|
||||
let game = source();
|
||||
for (const recipient of ['e','f']) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => act(game,'h',question(recipient)));
|
||||
assert.deepEqual(game,before);
|
||||
}
|
||||
game = act(game,'h',question('h'));
|
||||
assert.deepEqual(viewGame(game,'a').truthBribeAnswers,['yes','no']);
|
||||
game = act(game,'a',{type:'truthAnswer',answer:'yes'});
|
||||
const view = viewGame(game,'a');
|
||||
assert.equal(view.bribePromiseGuide?.action.target,'h');
|
||||
const html = renderToStaticMarkup(createElement(BribePromiseGuideButton,{game:view,busy:false,send() {}}));
|
||||
assert.match(html,/Pay 3 spice to Harkonnen/);
|
||||
assert.doesNotMatch(html,/disabled=""/);
|
||||
game = act(game,'a',view.bribePromiseGuide!.action);
|
||||
assert.equal(game.players.find(player => player.id === 'a')!.spice,7);
|
||||
assert.equal(game.players.find(player => player.id === 'h')!.bribes,3);
|
||||
});
|
||||
|
||||
void test('mixed bribe fields reject group allies while accepting the outsider', () => {
|
||||
const view = viewGame(source(),'h');
|
||||
const expression = (recipient:string) => ({kind:'and',terms:[
|
||||
{kind:'fact',fact:{kind:'spice',compare:'gte',value:1}},
|
||||
{kind:'action',action:{recipient,minimum:3}},
|
||||
]});
|
||||
assert.ok(mixedBribeInputError(expression('e'),view,'a'));
|
||||
assert.ok(mixedBribeInputError(expression('f'),view,'a'));
|
||||
assert.equal(mixedBribeInputError(expression('h'),view,'a'),null);
|
||||
});
|
||||
Reference in new issue
Block a user