Prototype two-player four-faction setup
This commit is contained in:
1 parent
70ef142577
commit
24255905ed
28 files changed
+1091
-73
No files matched your search
@@ -0,0 +1,98 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
TwoPlayerControllersError,
|
||||
addTwoPlayerControlledSeat as add,
|
||||
controllerStrongholdTarget as target,
|
||||
createTwoPlayerControllers as create,
|
||||
physicalControllerCount as count,
|
||||
validateTwoPlayerControllersComplete as complete,
|
||||
validateTwoPlayerControllersLobby as lobby,
|
||||
type TwoPlayerControllers,
|
||||
} from '../game/two-player-controllers';
|
||||
|
||||
const seats = ['atreides', 'harkonnen', 'fremen', 'guild'];
|
||||
function assembled(): TwoPlayerControllers {
|
||||
let state = create();
|
||||
state = add(state, seats, 'atreides', 'atreides');
|
||||
state = add(state, seats, 'atreides', 'fremen');
|
||||
state = add(state, seats, 'harkonnen', 'harkonnen');
|
||||
return add(state, seats, 'harkonnen', 'guild');
|
||||
}
|
||||
|
||||
void test('lobby additions create two distinct primary controllers without mutating inputs', () => {
|
||||
const empty = create(), first = add(empty, seats, 'atreides', 'atreides');
|
||||
const prior = structuredClone(first);
|
||||
const second = add(first, seats, 'atreides', 'fremen');
|
||||
assert.deepEqual(empty, { version: 1, controllers: [] });
|
||||
assert.deepEqual(first, prior);
|
||||
assert.notEqual(second.controllers, first.controllers);
|
||||
assert.notEqual(second.controllers[0].seats, first.controllers[0].seats);
|
||||
lobby(second, seats);
|
||||
assert.throws(() => complete(second, seats), TwoPlayerControllersError);
|
||||
const ready = assembled();
|
||||
complete(ready, seats);
|
||||
assert.deepEqual(ready.controllers, [
|
||||
{ id: 'atreides', seats: ['atreides', 'fremen'] },
|
||||
{ id: 'harkonnen', seats: ['harkonnen', 'guild'] },
|
||||
]);
|
||||
});
|
||||
|
||||
void test('full mapping counts two people and keeps faction alliances separate', () => {
|
||||
const state = assembled();
|
||||
assert.equal(count(seats, state), 2);
|
||||
assert.equal(target(seats, state, false), 4);
|
||||
assert.equal(target(seats, state, true), 4);
|
||||
assert.deepEqual(state.controllers[0].seats, ['atreides', 'fremen']);
|
||||
// Without the explicit mode, four faction seats remain four players.
|
||||
assert.equal(count(seats), 4);
|
||||
assert.equal(target(seats, undefined, false), 3);
|
||||
assert.equal(target(seats, undefined, true), 4);
|
||||
assert.equal(target(seats.slice(0, 2), null, false), 4);
|
||||
assert.equal(count(['a', 'b', 'c', 'd', 'e', 'f']), 6);
|
||||
});
|
||||
|
||||
void test('lobby mapping rejects duplicate, foreign, missing and forged primary assignments', () => {
|
||||
for (const value of [
|
||||
{ version: 1, controllers: [{ id: 'atreides', seats: ['atreides', 'atreides'] }] },
|
||||
{ version: 1, controllers: [{ id: 'atreides', seats: ['fremen'] }] },
|
||||
{ version: 1, controllers: [{ id: 'atreides', seats: ['atreides', 'foreign'] }] },
|
||||
{ version: 1, controllers: [
|
||||
{ id: 'atreides', seats: ['atreides', 'fremen'] },
|
||||
{ id: 'harkonnen', seats: ['harkonnen', 'fremen'] },
|
||||
] },
|
||||
{ version: 1, controllers: [{ id: 'atreides', seats: [] }] },
|
||||
{ version: 1, controllers: [{ id: 'atreides', seats: ['atreides'], secret: 'x' }] },
|
||||
{ version: 2, controllers: [] },
|
||||
{ version: 1, controllers: [], extra: true },
|
||||
]) assert.throws(() => lobby(value, seats), TwoPlayerControllersError);
|
||||
const state = assembled(), snapshot = structuredClone(state);
|
||||
assert.throws(() => lobby(state, seats.slice(0, 3)), TwoPlayerControllersError);
|
||||
assert.throws(() => lobby(state, ['atreides', 'atreides']), TwoPlayerControllersError);
|
||||
assert.deepEqual(state, snapshot);
|
||||
});
|
||||
|
||||
void test('incremental additions cannot steal, duplicate or exceed a controller seat', () => {
|
||||
let state = add(create(), seats, 'atreides', 'atreides');
|
||||
const before = structuredClone(state);
|
||||
assert.throws(() => add(state, seats, 'atreides', 'atreides'), TwoPlayerControllersError);
|
||||
assert.throws(() => add(state, seats, 'harkonnen', 'guild'), TwoPlayerControllersError);
|
||||
assert.throws(() => add(state, seats, 'atreides', 'unknown'), TwoPlayerControllersError);
|
||||
assert.deepEqual(state, before);
|
||||
state = add(state, seats, 'atreides', 'fremen');
|
||||
assert.throws(() => add(state, seats, 'atreides', 'guild'), TwoPlayerControllersError);
|
||||
state = add(state, seats, 'harkonnen', 'harkonnen');
|
||||
assert.throws(() => add(state, seats, 'guild', 'guild'), TwoPlayerControllersError);
|
||||
});
|
||||
|
||||
void test('a partial or malformed mapping cannot alter victory calculations', () => {
|
||||
const partial = add(create(), seats, 'atreides', 'atreides');
|
||||
assert.throws(() => count(seats, partial), TwoPlayerControllersError);
|
||||
assert.throws(() => target(seats, partial, false), TwoPlayerControllersError);
|
||||
assert.throws(() => target(seats, partial, true), TwoPlayerControllersError);
|
||||
assert.throws(() => count([], undefined), TwoPlayerControllersError);
|
||||
assert.throws(() => count(['a', 'a'], undefined), TwoPlayerControllersError);
|
||||
const restored = JSON.parse(JSON.stringify(assembled())) as TwoPlayerControllers;
|
||||
complete(restored, seats);
|
||||
assert.equal(target(seats, restored, false), 4);
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import type { GameView } from '../game/engine';
|
||||
import { TwoPlayerControls } from '../components/two-player-controls';
|
||||
|
||||
const base = {
|
||||
code: 'TWOPLAY2', status: 'lobby', host: 'a', me: 'a', expansions: [],
|
||||
players: [
|
||||
{ id: 'a', name: 'Ali', faction: 'atreides', ready: true },
|
||||
{ id: 'b', name: 'Bea', faction: 'harkonnen', ready: false },
|
||||
{ id: 'c', name: 'Ali', faction: 'emperor', ready: false },
|
||||
],
|
||||
twoPlayerControllers: { version: 1, controllers: [
|
||||
{ id: 'one', seats: ['a', 'c'] }, { id: 'two', seats: ['b'] },
|
||||
] },
|
||||
controlledFactionSeats: [
|
||||
{ id: 'a', name: 'Ali', faction: 'atreides' },
|
||||
{ id: 'c', name: 'Ali', faction: 'emperor' },
|
||||
],
|
||||
} as unknown as GameView;
|
||||
const noop = () => {};
|
||||
function render(game: GameView, pendingEntry = false) {
|
||||
return renderToStaticMarkup(createElement(TwoPlayerControls, {
|
||||
game, disabled: pendingEntry, pendingEntry, entryRoom: game.code, entryProblem: '',
|
||||
retryDisabled: false, onModeChange: noop, onSwitch: noop, onAdd: noop, onRetry: noop,
|
||||
}));
|
||||
}
|
||||
|
||||
void test('faction selector only offers the authenticated controller seats', () => {
|
||||
const html = render(base);
|
||||
assert.match(html, /Active faction/);
|
||||
assert.match(html, /value="a"/);
|
||||
assert.match(html, /value="c"/);
|
||||
assert.doesNotMatch(html, /<option[^>]*value="b"/);
|
||||
assert.match(html, /Player 2: Harkonnen \(not ready\) — needs a second faction/);
|
||||
});
|
||||
|
||||
void test('second faction choices omit taken factions and disabled expansions', () => {
|
||||
const html = render({ ...base, me: 'b', controlledFactionSeats: [{ id: 'b', name: 'Bea', faction: 'harkonnen' }] });
|
||||
assert.match(html, /Add your second faction/);
|
||||
assert.match(html, /value="fremen"/);
|
||||
assert.doesNotMatch(html, /<option[^>]*value="atreides"/);
|
||||
assert.doesNotMatch(html, /<option[^>]*value="ixians"/);
|
||||
});
|
||||
|
||||
void test('an unresolved request stays visible with an explicit retry', () => {
|
||||
const html = render(base, true);
|
||||
assert.match(html, /Retry saved second-faction request/);
|
||||
assert.match(html, /never retried automatically/);
|
||||
assert.match(html, /<select[^>]*disabled/);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, joinControlledFaction, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
|
||||
|
||||
function lobby(): Game {
|
||||
let game = createGame('TWOPEOPLE', newPlayer('a', 'A', 'atreides'));
|
||||
game = applyAction(game, 'a', { type: 'twoPlayerMode', enabled: true });
|
||||
joinControlledFaction(game, newPlayer('b', 'A second', 'emperor'), 'a');
|
||||
joinGame(game, newPlayer('c', 'C', 'guild'));
|
||||
joinControlledFaction(game, newPlayer('d', 'C second', 'harkonnen'), 'c');
|
||||
return game;
|
||||
}
|
||||
|
||||
void test('two people can independently ready their two physical factions and finish setup', () => {
|
||||
let game = lobby();
|
||||
assert.deepEqual(game.twoPlayerControllers, { version: 1, controllers: [
|
||||
{ id: 'a', seats: ['a', 'b'] }, { id: 'c', seats: ['c', 'd'] },
|
||||
] });
|
||||
assert.deepEqual(viewGame(game, 'b').controlledFactionSeats.map(seat => seat.id), ['a', 'b']);
|
||||
assert.deepEqual(viewGame(game, 'd').controlledFactionSeats.map(seat => seat.id), ['c', 'd']);
|
||||
for (const id of ['a', 'b', 'c', 'd']) game = applyAction(game, id, { type: 'ready' });
|
||||
game = applyAction(game, 'a', { type: 'start' });
|
||||
assert.equal(game.status, 'setup');
|
||||
for (const id of ['a', 'b', 'c', 'd']) {
|
||||
const privateView = viewGame(game, id);
|
||||
for (const seat of privateView.players) {
|
||||
assert.equal('traitorChoices' in seat, seat.id === id);
|
||||
assert.equal('hand' in seat, seat.id === id);
|
||||
}
|
||||
}
|
||||
// Harkonnen automatically keeps all four dealt traitors; the other factions
|
||||
// each choose one before play begins.
|
||||
assert.equal(game.players.find(player => player.id === 'd')!.traitors.length, 4);
|
||||
assert.equal(game.players.find(player => player.id === 'd')!.traitorChoices.length, 0);
|
||||
for (const id of ['a', 'b', 'c']) {
|
||||
const player = game.players.find(seat => seat.id === id)!;
|
||||
game = applyAction(game, id, { type: 'traitor', leader: player.traitorChoices[0] });
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
assert.equal(game.players.every(player => player.ally === null), true);
|
||||
if (game.phaseOpening) {
|
||||
for (const id of ['a', 'b', 'c', 'd']) game = applyAction(game, id, { type: 'ready' });
|
||||
}
|
||||
const dialers = [...game.stormDialers];
|
||||
for (const id of dialers) game = applyAction(game, id, { type: 'stormDial', amount: 1 });
|
||||
assert.equal(game.stormPending, 2);
|
||||
});
|
||||
|
||||
void test('lobby preserves separate controller seats and refuses incomplete, excess and AI setup', () => {
|
||||
let game = createGame('TWOPEOPLE', newPlayer('a', 'A', 'atreides'));
|
||||
game = applyAction(game, 'a', { type: 'twoPlayerMode', enabled: true });
|
||||
assert.throws(() => joinControlledFaction(game, newPlayer('x', 'X', 'emperor'), 'other'), /controller/);
|
||||
joinControlledFaction(game, newPlayer('b', 'B', 'emperor'), 'a');
|
||||
assert.throws(() => joinControlledFaction(game, newPlayer('x', 'X', 'guild'), 'b'), /two faction/);
|
||||
assert.throws(() => applyAction(game, 'a', { type: 'twoPlayerMode', enabled: false }), /cannot be disabled/);
|
||||
assert.throws(() => applyAction(game, 'a', { type: 'addBot', faction: 'guild', difficulty: 'Easy' }), /human/);
|
||||
game = applyAction(game, 'a', { type: 'ready' });
|
||||
game = applyAction(game, 'b', { type: 'ready' });
|
||||
assert.throws(() => applyAction(game, 'a', { type: 'start' }), /exactly two controllers/);
|
||||
assert.equal(game.players.length, 2);
|
||||
});
|
||||
|
||||
void test('forged or missing controller assignments reject without mutating the game', () => {
|
||||
const game = lobby();
|
||||
game.twoPlayerControllers!.controllers[1].seats[1] = 'b';
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => viewGame(game, 'a'), /only one controller/);
|
||||
assert.throws(() => applyAction(game, 'a', { type: 'ready' }), /only one controller/);
|
||||
assert.deepEqual(game, before);
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
// @dune-suite integration
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomBytes, randomUUID } from 'node:crypto';
|
||||
|
||||
const base = process.env.DUNE_TEST_URL ?? 'http://localhost:3000';
|
||||
const receipt = () => ({ operationId: randomUUID(), sessionToken: randomBytes(32).toString('hex') });
|
||||
type Wire = { code: string; me: string; version: number; players: { id: string; ready: boolean; hand?: unknown[] }[]; error?: string; entryReceipt?: { operationId: string; replayed: boolean } };
|
||||
async function request(path: string, body?: unknown, cookie?: string, selected?: string) {
|
||||
const res = await fetch(base + path, {
|
||||
method: body === undefined ? 'GET' : 'POST',
|
||||
headers: {
|
||||
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
||||
...(cookie ? { cookie } : {}),
|
||||
...(selected ? { 'X-Dune-Seat': selected } : {}),
|
||||
},
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
return { status: res.status, data: await res.json() as Wire, cookie: res.headers.get('set-cookie')?.split(';')[0] };
|
||||
}
|
||||
|
||||
void test('two-person controlled seat creation, retry and selected credentials remain isolated', async () => {
|
||||
const host = await request('/api/rooms', { name: 'Two person host', faction: 'atreides', advanced: false, expansions: [], entry: receipt() });
|
||||
assert.equal(host.status, 201, JSON.stringify(host.data));
|
||||
assert.ok(host.cookie);
|
||||
const path = `/api/rooms/${host.data.code}`;
|
||||
const enabled = await request(path, { version: host.data.version, action: { type: 'twoPlayerMode', enabled: true } }, host.cookie, host.data.me);
|
||||
assert.equal(enabled.status, 200, JSON.stringify(enabled.data));
|
||||
const attempt = { type: 'addControlledFaction', version: enabled.data.version, name: 'Host second faction', faction: 'harkonnen', entry: receipt() };
|
||||
const added = await request(path, attempt, host.cookie, host.data.me);
|
||||
assert.equal(added.status, 200, JSON.stringify(added.data));
|
||||
assert.ok(added.cookie?.startsWith(`dune_${host.data.code}_${added.data.me}=`));
|
||||
assert.notEqual(added.data.me, host.data.me);
|
||||
assert.equal(added.data.players.length, 2);
|
||||
assert.deepEqual(added.data.entryReceipt, { operationId: attempt.entry.operationId, replayed: false });
|
||||
assert.ok(!JSON.stringify(added.data).includes(attempt.entry.sessionToken));
|
||||
const both = `${host.cookie}; ${added.cookie}`;
|
||||
const retry = await request(path, attempt, both, host.data.me);
|
||||
assert.equal(retry.status, 200, JSON.stringify(retry.data));
|
||||
assert.equal(retry.cookie, added.cookie);
|
||||
assert.equal(retry.data.me, added.data.me);
|
||||
assert.deepEqual(retry.data.entryReceipt, { operationId: attempt.entry.operationId, replayed: true });
|
||||
const altered = await request(path, { ...attempt, version: attempt.version + 1 }, both, host.data.me);
|
||||
assert.equal(altered.status, 409);
|
||||
assert.equal(altered.cookie, undefined);
|
||||
for (const id of [host.data.me, added.data.me]) {
|
||||
const view = await request(path, undefined, both, id);
|
||||
assert.equal(view.status, 200, JSON.stringify(view.data));
|
||||
assert.equal(view.data.me, id);
|
||||
const talk = await request(`${path}/messages`, undefined, both, id);
|
||||
assert.equal(talk.status, 200, JSON.stringify(talk.data));
|
||||
}
|
||||
const missing = await request(path, undefined, host.cookie, added.data.me);
|
||||
assert.equal(missing.status, 409);
|
||||
const forged = await request(path, undefined, both, randomUUID());
|
||||
assert.equal(forged.status, 409);
|
||||
const defaultSeat = await request(path, undefined, both);
|
||||
assert.equal(defaultSeat.data.me, host.data.me);
|
||||
const stranger = await request(path, { type: 'join', name: 'Other person', faction: 'emperor', entry: receipt() });
|
||||
assert.equal(stranger.status, 200, JSON.stringify(stranger.data));
|
||||
assert.ok(stranger.cookie);
|
||||
const stolenReceipt = await request(path, attempt, stranger.cookie, stranger.data.me);
|
||||
assert.equal(stolenReceipt.status, 409);
|
||||
assert.equal(stolenReceipt.cookie, undefined);
|
||||
const wrongSeat = await request(`${path}/control`, { type: 'setRecoveryKey', version: stranger.data.version, recoverySecret: randomBytes(32).toString('hex') }, host.cookie, added.data.me);
|
||||
assert.equal(wrongSeat.status, 409);
|
||||
const secondAttempt = { type: 'addControlledFaction', version: stranger.data.version, name: 'Other second faction', faction: 'fremen', entry: receipt() };
|
||||
const second = await request(path, secondAttempt, stranger.cookie, stranger.data.me);
|
||||
assert.equal(second.status, 200, JSON.stringify(second.data));
|
||||
assert.ok(second.cookie?.startsWith(`dune_${host.data.code}_${second.data.me}=`));
|
||||
const otherCookies = `${stranger.cookie}; ${second.cookie}`;
|
||||
const seats = [
|
||||
{ id: host.data.me, cookie: both },
|
||||
{ id: added.data.me, cookie: both },
|
||||
{ id: stranger.data.me, cookie: otherCookies },
|
||||
{ id: second.data.me, cookie: otherCookies },
|
||||
];
|
||||
const crossController = await request(path, undefined, both, second.data.me);
|
||||
assert.equal(crossController.status, 409);
|
||||
const reverseCrossController = await request(path, undefined, otherCookies, added.data.me);
|
||||
assert.equal(reverseCrossController.status, 409);
|
||||
let latest = second.data;
|
||||
for (const seat of seats) {
|
||||
const view = await request(path, undefined, seat.cookie, seat.id);
|
||||
assert.equal(view.status, 200, JSON.stringify(view.data));
|
||||
assert.equal(view.data.me, seat.id);
|
||||
for (const player of view.data.players)
|
||||
assert.equal(Object.hasOwn(player, 'hand'), player.id === seat.id);
|
||||
const ready = await request(path, { version: latest.version, action: { type: 'ready' } }, seat.cookie, seat.id);
|
||||
assert.equal(ready.status, 200, JSON.stringify(ready.data));
|
||||
assert.equal(ready.data.me, seat.id);
|
||||
latest = ready.data;
|
||||
}
|
||||
});
|
||||
|
||||
void test('recovering a scoped seat replaces its stale cookie without requiring a selector', async () => {
|
||||
const host = await request('/api/rooms', { name: 'Recovery host', faction: 'atreides', advanced: false, expansions: [], entry: receipt() });
|
||||
assert.equal(host.status, 201, JSON.stringify(host.data));
|
||||
assert.ok(host.cookie);
|
||||
const path = `/api/rooms/${host.data.code}`;
|
||||
const enabled = await request(path, { version: host.data.version, action: { type: 'twoPlayerMode', enabled: true } }, host.cookie, host.data.me);
|
||||
assert.equal(enabled.status, 200, JSON.stringify(enabled.data));
|
||||
const added = await request(path, { type: 'addControlledFaction', version: enabled.data.version, name: 'Recovery second', faction: 'harkonnen', entry: receipt() }, host.cookie, host.data.me);
|
||||
assert.equal(added.status, 200, JSON.stringify(added.data));
|
||||
assert.ok(added.cookie);
|
||||
const recoverySecret = randomBytes(32).toString('hex');
|
||||
const setup = await request(`${path}/control`, { type: 'setRecoveryKey', version: added.data.version, recoverySecret }, added.cookie, added.data.me);
|
||||
assert.equal(setup.status, 200, JSON.stringify(setup.data));
|
||||
// Another browser recovers first, making the original scoped cookie stale.
|
||||
const initial = await request(`${path}/control`, { type: 'recoverSeat', playerId: added.data.me, recoverySecret, operationId: randomUUID(), newSessionToken: randomBytes(32).toString('hex') });
|
||||
assert.equal(initial.status, 200, JSON.stringify(initial.data));
|
||||
const stale = await request(path, undefined, added.cookie, added.data.me);
|
||||
assert.equal(stale.status, 409);
|
||||
const attempt = { type: 'recoverSeat', playerId: added.data.me, recoverySecret, operationId: randomUUID(), newSessionToken: randomBytes(32).toString('hex') };
|
||||
const recovered = await request(`${path}/control`, attempt, added.cookie);
|
||||
assert.equal(recovered.status, 200, JSON.stringify(recovered.data));
|
||||
assert.ok(recovered.cookie?.startsWith(`dune_${host.data.code}_${added.data.me}=`));
|
||||
const view = await request(path, undefined, recovered.cookie, added.data.me);
|
||||
assert.equal(view.status, 200, JSON.stringify(view.data));
|
||||
assert.equal(view.data.me, added.data.me);
|
||||
const retry = await request(`${path}/control`, attempt, added.cookie);
|
||||
assert.equal(retry.status, 200, JSON.stringify(retry.data));
|
||||
assert.equal(retry.cookie, recovered.cookie);
|
||||
});
|
||||
@@ -8,7 +8,8 @@ import type { BoardContext, BoardSeat } from '../game/board-resolution-quote';
|
||||
import type { Game } from '../game/engine';
|
||||
import { createTechTokens, TECH_TOKENS } from '../game/tech-tokens';
|
||||
import { MOBILE_STRONGHOLD, MOBILE_LOCATION } from '../game/board';
|
||||
type Context = BoardContext & Pick<Game, 'techTokens'>;
|
||||
import type { TwoPlayerControllers } from '../game/two-player-controllers';
|
||||
type Context = BoardContext & Pick<Game, 'techTokens'> & { twoPlayerControllers?: TwoPlayerControllers };
|
||||
const keys = [
|
||||
'arrakeen:10',
|
||||
'carthag:11',
|
||||
@@ -94,6 +95,24 @@ void test('unallied target is three except in a two-player game', () => {
|
||||
occupy(g, 'e', keys);
|
||||
assert.equal(row(g).qualifies, true);
|
||||
});
|
||||
void test('two controllers with four factions need four holds without pooling their separate factions', () => {
|
||||
const g = fixture(false);
|
||||
g.players = [...g.players, { id: 'h', faction: 'harkonnen', ally: null, forces: {} }];
|
||||
g.order = [...g.order, 'h'];
|
||||
g.twoPlayerControllers = { version: 1, controllers: [
|
||||
{ id: 'e', seats: ['e', 'a'] }, { id: 'x', seats: ['x', 'h'] },
|
||||
] };
|
||||
occupy(g, 'e', keys.slice(0, 3));
|
||||
occupy(g, 'a', [keys[3]]);
|
||||
assert.equal(row(g).target, 4);
|
||||
assert.deepEqual(row(g).members, ['e']);
|
||||
assert.equal(row(g).qualifies, false);
|
||||
occupy(g, 'a', []);
|
||||
occupy(g, 'e', keys);
|
||||
assert.equal(row(g).qualifies, true);
|
||||
g.twoPlayerControllers.controllers[1].seats.pop();
|
||||
assert.throws(() => strongholdProgress(g), VictoryProgressError);
|
||||
});
|
||||
void test('technology requires one member to hold all three and never supplies a joint territory', () => {
|
||||
const g = fixture();
|
||||
occupy(g, 'e', keys.slice(0, 2));
|
||||
|
||||
@@ -97,6 +97,18 @@ void test('two seats require four normal holds', () => {
|
||||
g.players[0].forces['tueks_sietch:5'] = 1;
|
||||
compare(g, ['p0']);
|
||||
});
|
||||
void test('two people controlling four factions require four holds for a single faction', () => {
|
||||
const g = fixture(['atreides', 'emperor', 'beneGesserit', 'harkonnen'], false);
|
||||
Object.assign(g, { twoPlayerControllers: { version: 1, controllers: [
|
||||
{ id: 'p0', seats: ['p0', 'p1'] }, { id: 'p2', seats: ['p2', 'p3'] },
|
||||
] } });
|
||||
occupy(g, 0, 'arrakeen', 'carthag', 'sietch_tabr');
|
||||
occupy(g, 1, 'tueks_sietch');
|
||||
compare(g, []);
|
||||
occupy(g, 1);
|
||||
g.players[0].forces['tueks_sietch:5'] = 1;
|
||||
compare(g, ['p0']);
|
||||
});
|
||||
void test('allies combine sole team occupancy at four, including a shared allied stronghold', () => {
|
||||
const g = fixture();
|
||||
g.players[0].ally = 'p1';
|
||||
|
||||
Reference in new issue
Block a user