Add Basic group alliance preview to lobby
Verify and publish checkpoint container / container (push) Successful in 10m49s

This commit is contained in:
Quintine committed 2026-10-29 03:55:42 +11:00
1 parent 44e7cf8001
commit 89386af95b
7 files changed
+273 -10

No files matched your search

+15
View File
@@ -2131,6 +2131,9 @@ export function GameTable({
{g.status === 'playing' && g.shortAllianceVictory && <p className="notice">
Optional alliance rule: a two-member alliance wins with three strongholds when at least three people or AI controllers are playing. Two-controller games retain their four-stronghold target.
</p>}
{(g.status === 'playing' || g.status === 'setup') && g.coalitionPreview !== 'off' && <p className="notice">
Group alliances are an unfinished preview. Some faction powers and interactions are incomplete; this game does not certify the full optional rules.
</p>}
{g.status === 'playing' && <MixedTraitorPromises game={g} />}
{g.status === 'playing' && <TravelPromises game={g} busy={busy} act={act} />}
{g.status === 'playing' && <TurnActionPromises game={g} busy={g.semutaReaction &&
@@ -2280,6 +2283,18 @@ export function GameTable({
onChange={(e) => act({type: 'shortAllianceVictory', enabled: e.target.checked})} />
Optional: two-member alliances win with three strongholds (three or more controllers)
</label>
<label>Group alliances · unfinished preview
<select value={g.coalitionPreview} disabled={busy || g.host !== me.id}
onChange={(e) => act({ type: 'coalitionPreview', variant: e.target.value })}>
<option value="off">Off</option>
<option value="three">Up to three members · four strongholds</option>
<option value="three-short">Up to three members · three strongholds</option>
<option value="unlimited">Unlimited members · three strongholds</option>
</select>
</label>
{g.coalitionPreview !== 'off' && <p className="notice">
This incomplete preview needs three to six classic factions in Basic rules, without expansion decks, other optional modules, two-person setup or the separate short-alliance setting. Some powers and interactions remain unfinished. Changing the selection clears human readiness.
</p>}
<label className="checkbox-row">
<input
type="checkbox"
+11 -2
View File
@@ -17,13 +17,22 @@ open mode gates.
## Current checkpoint and work
**Basic classic group lobby preview — 29 October. Prototyped / Partial.**
The host can select a visibly unfinished group-alliance variant for a fresh
three-to-six-player Basic classic game. Start rejects incompatible modules and
rules. A native setup and Nexus journey forms a three-member group, continues
through the next turn and exercises legal bot proposals. Twenty-nine related
cases, typecheck, lint and the production build pass. Full group interactions,
expansion/Advanced entry and deployed acceptance remain open.
[Scope](OPTIONAL_ALLIANCE_VICTORY.md#basic-classic-group-lobby-preview--29-october-2026).
**Group Fremen–Guild shipment promise — 29 October. Prototyped / Partial.**
In a staged Basic group, the Truthtrance shipment search now recognizes a legal
Fremen reserve shipment through its Guild ally. A focused journey forms the
group through Nexus, stages Movement, then completes the real question and
paid shipment; all four bot profiles have a legal path. Twenty-five related
cases, typecheck, lint and the production build pass. Group lobby admission
and full rules acceptance remain open.
cases, typecheck, lint and the production build pass. The later Basic lobby
preview above does not establish full group rules acceptance.
[Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-fremenguild-shipment-answer--29-october-2026).
**Truthtrance card types — 29 October. Prototyped / Partial.** Verified
+26
View File
@@ -1,5 +1,31 @@
# Optional three-stronghold alliance victory
## Basic classic group lobby preview — 29 October 2026
**Prototyped / Partial.** A host may explicitly select a group-alliance
development preview in a fresh Basic game with three or more classic faction
seats. The available variants are a maximum of three members with four
strongholds, a maximum of three with three strongholds, or any number of
members with three strongholds. Changing the setting clears human readiness.
The separate short-alliance option, two-controller setup, Advanced, expansions
and other optional modules are excluded from this initial lobby entry.
Start creates the empty group ledger through the ordinary setup path; it does
not modify existing games. Existing pair offers, unanimous group admission and
leave, the staged faction benefits and minimal bot paths then apply. The game
and lobby warn that group timing, benefits and other interactions remain
unfinished. This preview does not certify complete group rules or open normal
mode/publication gates; historical statements below that no lobby could create
a ledger describe earlier checkpoints.
The [focused journey](../tests/coalition-preview-lobby.test.ts) uses genuine
public start, setup and first-turn actions; only the unopened Spice deck order
is chosen to reach the next Nexus. Pair formation, group consent and bot
proposals use native actions; the resulting alliance continues through the
following turn to the next Storm. It also covers settings, readiness and invalid
starts. Full group games, later interactions, expansion and Advanced entry,
and deployed acceptance remain open.
## Group Fremen–Guild shipment answer — 29 October 2026
**Prototyped / Partial.** In the staged Basic group, a Fremen member can
+30 -6
View File
@@ -752,7 +752,7 @@ import {
} from './advisors';
import { isAlliedShipmentVisit, makeBasicAlliedShipment, quoteBasicAlliedShipment, basicAlliedShipmentHasWitness,
type BasicAlliedShipment } from './allied-separation';
import { answerCoalitionAdmission, beginCoalitionAdmission, coalitionAllies, coalitionGroups, coalitionOf,
import { answerCoalitionAdmission, beginCoalitionAdmission, coalitionAllies, coalitionGroups, coalitionOf, emptyCoalitionState,
leaveCoalition, CoalitionAllianceError, type CoalitionState, type CoalitionTransition } from './coalition-alliance';
import { coalitionBattleBeneficiary } from './coalition-battle-preparation';
import { createCoalitionWormProtection, currentCoalitionWormAlly, decideCoalitionWormProtection,
@@ -1448,7 +1448,9 @@ type RicheseAllyOffer = {
payer: string;
};
export type Game = {
/** Unexposed group-alliance integration state; absent in existing games. */
/** Explicit incomplete Basic group preview; absent in legacy games. */
coalitionPreview?: 'three' | 'three-short' | 'unlimited';
/** Group-alliance integration state; absent in ordinary existing games. */
coalitionState?: CoalitionState;
coalitionEscrow?: CoalitionEscrowState;
coalitionWormProtection?: CoalitionWormProtectionFrame;
@@ -5804,6 +5806,8 @@ function twoPlayerRule<T>(operation: () => T): T {
function twoPlayerControllersIntegrity(g: Game): void {
requireRule(g.shortAllianceVictory === undefined || typeof g.shortAllianceVictory === 'boolean',
'The optional alliance victory setting must be true or false.');
requireRule(g.coalitionPreview === undefined || ['three', 'three-short', 'unlimited'].includes(g.coalitionPreview),
'Invalid group-alliance preview setting.');
if (g.twoPlayerControllers === undefined) return;
const seats = g.players.map(player => player.id);
twoPlayerRule(() => {
@@ -10881,6 +10885,12 @@ function startModules(state: Game): Game {
}
/** Public consistency only; each module's genuine initializer validates its own envelope. */
function baseStartProfileBlock(g: Game): string | null {
if (g.coalitionPreview && g.status === 'lobby' &&
(g.advanced || g.expansions.length > 0 || g.players.length < 3 ||
!g.players.every(p => CLASSIC_FACTIONS[p.faction] === true) || g.twoPlayerControllers ||
g.shortAllianceVictory || g.techTokens || g.strongholdCards || g.homeworlds ||
g.nexusCards || g.discoveryEnabled || g.leaderSkills || g.ecazTreachery || lobbyModulesSelected(g)))
return 'The group-alliance preview needs at least three classic factions, Basic rules, and no expansion decks, other optional modules, two-person setup or separate short-alliance setting.';
if (g.status === 'lobby' && (g.moritaniAssassinatePreview || g.moritaniAssassinate))
return 'Separate development profiles cannot be enabled by a normal start.';
if (g.status === 'lobby' && (g.leaderSkills || g.ecazTreachery))
@@ -10945,7 +10955,7 @@ function requireFreshBaseRuntime(g: Game) {
const configuration = new Set<keyof Game>(['code', 'version', 'host', 'players', 'playerPositions',
'advanced', 'expansions', 'log', 'botsPending', 'botNextActionAt', 'techTokens', 'strongholdCards',
'homeworlds', 'nexusCards', 'discoveryEnabled', 'leaderSkills', 'ecazTreachery', 'lobbyOptions', 'kullDefaultPolicy',
'twoPlayerControllers', 'shortAllianceVictory']);
'twoPlayerControllers', 'shortAllianceVictory', 'coalitionPreview']);
const keys = [...new Set([...Object.keys(g), ...Object.keys(fresh)])] as (keyof Game)[];
requireRule(keys.every(key => configuration.has(key) ||
JSON.stringify(g[key]) === JSON.stringify(fresh[key])),
@@ -10982,6 +10992,8 @@ function start(g: Game, advancedPreview = false): Game {
'Start with unused Stronghold Cards before assigning ownership.');
requireFreshBaseRuntime(g);
initializeSetup(g);
if (g.coalitionPreview) g.coalitionState = emptyCoalitionState(
g.coalitionPreview === 'three' ? undefined : g.coalitionPreview);
if (advancedPreview) g.advancedPreview = true;
return g;
}
@@ -16278,9 +16290,8 @@ function allianceQuestionWindow(g: Game): boolean {
function unsettledPromises(records: readonly {fulfilled?: boolean; released?: boolean}[] | undefined): boolean {
return !!records?.some(record => !record.fulfilled && !record.released);
}
/** Group negotiation is deliberately unexposed until its other alliance
* mechanics are integrated. This narrow profile keeps existing pair games
* and unrelated commitments on their original paths. */
/** The incomplete group preview and staged fixtures retain this narrow profile;
* existing pair games and unrelated commitments keep their original paths. */
function coalitionNexusAvailable(g: Game): boolean {
return g.coalitionState !== undefined && g.status === 'playing' &&
g.phase === 1 && !!g.nexus && !g.spiceWindow && !g.spiceResolution &&
@@ -44695,6 +44706,18 @@ function applyActionInner(
log(g, `${p.name} ${action.enabled ? 'enabled' : 'disabled'} the provisional two-person, four-faction setup. Human readiness was cleared.`);
return g;
}
if (t === 'coalitionPreview') {
requireRule(id === g.host && Object.keys(action).every(key => key === 'type' || key === 'variant') &&
(action.variant === 'off' || action.variant === 'three' || action.variant === 'three-short' || action.variant === 'unlimited'),
'Only the host may choose a group-alliance preview option.');
const variant = action.variant === 'off' ? undefined : action.variant;
if (variant === g.coalitionPreview) return g;
if (variant) g.coalitionPreview = variant;
else delete g.coalitionPreview;
g.players.forEach(player => (player.ready = !!player.bot));
log(g, `${p.name} changed the unfinished group-alliance preview. Human readiness was cleared.`);
return g;
}
if (t === 'shortAllianceVictory') {
requireRule(id === g.host && typeof action.enabled === 'boolean' &&
Object.keys(action).every(key => key === 'type' || key === 'enabled'),
@@ -47056,6 +47079,7 @@ export function viewGame(state: Game, id: string, now?: number) {
factionSwap: g.factionSwap ?? null,
twoPlayerControllers: g.twoPlayerControllers ?? null,
shortAllianceVictory: g.shortAllianceVictory === true,
coalitionPreview: g.coalitionPreview ?? 'off',
controlledFactionSeats: g.twoPlayerControllers
? g.players.filter(player => g.twoPlayerControllers!.controllers.some(controller =>
controller.seats.includes(id) && controller.seats.includes(player.id)))
+3 -2
View File
@@ -2697,11 +2697,12 @@ export const RULE_TOPICS: RuleTopic[] = [
'The table chronicle records proposals, formation, withdrawals and breaks. Changing an offer clears Nexus readiness so the table can review the updated negotiations.',
'Refresh restores saved offers and alliances. If acceptance and withdrawal arrive together, only one action is committed; refresh or reconcile a changed table before choosing again. Pending responses and decisions must finish first.',
'Allied forces, cards and spice keep their owners. Alliance funding and individual faction powers use their own controls; special alliances and expansion restrictions remain separate.',
'The host may select an unfinished Basic classic group-alliance preview for three to six players. The group panel supports unanimous admission and leaving; powers and interactions remain incomplete. Expansion decks, other modules and the separate short-alliance setting cannot be combined with this preview.',
],
related: ['spice-blow', 'alliance-funding', 'table-discussion', 'privacy'],
checklist: [
{ area: 'Implementation', status: 'Partial', detail: 'Existing ordinary reciprocal offers, withdrawal and break preserve readiness and configured alliance restrictions. Staged Basic and bare-classic Advanced group Nexus actions add unanimous admission and leave; the option is not yet available in the lobby and broader group mechanics remain unfinished.', evidence: ['game/nexus-alliance.ts', 'game/coalition-alliance.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Named outgoing offers have live withdrawal. The group panel is connected in staged games only; no lobby enables group play yet.', evidence: ['components/game-table.tsx', 'components/coalition-nexus.tsx'] },
{ area: 'Implementation', status: 'Partial', detail: 'Ordinary reciprocal offers, withdrawal and break preserve readiness and configured alliance restrictions. The Basic classic group preview adds unanimous admission and leave; broader group mechanics remain unfinished.', evidence: ['game/nexus-alliance.ts', 'game/coalition-alliance.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'Named outgoing offers have live withdrawal. The host can select an explicitly incomplete Basic group preview; group controls appear during a supported Nexus.', evidence: ['components/game-table.tsx', 'components/coalition-nexus.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All four existing profiles can continue legally after withdrawal and answer staged group invitations. Full diplomacy and difficulty tuning remain deferred.' },
{ area: 'Documentation', status: 'Implemented', detail: 'Offer versus alliance, incoming invitations, readiness, refresh and concurrent choices are explained.', evidence: ['docs/NEXUS_OFFERS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Full-table rendering, JSON continuation, pending-window rejection and SQLite acceptance/withdrawal races are covered. Staged group cases exercise actual proposals, answers, leaving, table rendering and legal bot responses. All-configuration and full visual acceptance remain open.', evidence: ['tests/coalition-nexus-engine.test.ts', 'tests/nexus-offer-controls.test.tsx', 'tests/nexus-offer-recovery.test.ts', 'tests/nexus-alliance.test.ts'] },
+63
View File
@@ -0,0 +1,63 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game, type GameView } from '../game/engine';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(specifier === 'next/image' ? 'vinext/shims/image' :
specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
},
load(url, context, next) {
if (!url.endsWith('.module.css')) return next(url, context);
const css = readFileSync(new URL(url), 'utf8');
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
return { format:'module', source:`export default ${JSON.stringify(classes)}`, shortCircuit:true };
},
});
const { GameTable } = await import('../components/game-table');
aliases.deregister();
function lobby(): Game {
const game = createGame('GROUPPREVIEWUI',newPlayer('at','Atreides','atreides'));
joinGame(game,newPlayer('em','Emperor','emperor'));
joinGame(game,newPlayer('gu','Guild','guild'));
return game;
}
const table = (game: GameView, busy = false) => renderToStaticMarkup(createElement(GameTable,
{game,busy,send:async () => {},onExit() {}}));
function groupLabel(html: string): string {
const label = html.match(/<label\b[^>]*>(?:(?!<\/label>)[\s\S])*Group alliances · unfinished preview(?:(?!<\/label>)[\s\S])*<\/label>/)?.[0];
assert.ok(label, 'The group-alliance preview selector is visible.');
return label;
}
void test('host sees each group variant and guests cannot change the selector', () => {
const game = lobby(), host = groupLabel(table(viewGame(game,'at')));
assert.match(host,/value="off"/);
assert.match(host,/value="three"/);
assert.match(host,/value="three-short"/);
assert.match(host,/value="unlimited"/);
assert.match(host,/Up to three members/);
assert.match(host,/Unlimited members/);
assert.doesNotMatch(host,/<select[^>]*disabled/);
assert.match(groupLabel(table(viewGame(game,'em'))),/<select[^>]*disabled/);
assert.match(groupLabel(table(viewGame(game,'at'),true)),/<select[^>]*disabled/);
});
void test('selected preview warns in the lobby and through real setup', () => {
let game = applyAction(lobby(),'at',{type:'coalitionPreview',variant:'three-short'});
const lobbyHtml = table(viewGame(game,'at'));
assert.match(lobbyHtml,/This incomplete preview needs three to six classic factions/);
assert.match(lobbyHtml,/Some powers and interactions remain unfinished/);
for (const seat of game.players) game = applyAction(game,seat.id,{type:'ready'});
game = applyAction(game,'at',{type:'start'});
for (const actor of ['at','em','gu']) {
const html = table(viewGame(game,actor));
assert.match(html,/Group alliances are an unfinished preview/);
assert.match(html,/Some faction powers and interactions are incomplete/);
}
});
+125
View File
@@ -0,0 +1,125 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { coalitionOf } from '../game/coalition-alliance';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { advanceShortAlliance, shortAllianceLobby, type ShortAllianceStep } from './fixture-short-alliance-victory';
const apply = (game: Game, actor: string, action: Action) => applyAction(game, actor, action);
function lobby(): Game {
const game = shortAllianceLobby();
joinGame(game, newPlayer('ha', 'Harkonnen', 'harkonnen'));
return game;
}
function ready(game: Game): Game {
for (const player of game.players) if (!player.ready) game = apply(game, player.id, {type:'ready'});
return game;
}
function answer(game: Game, actor: string): Game {
const proposal = game.coalitionState?.proposal; assert.ok(proposal);
return apply(game, actor, {type:'coalitionAnswer', event:proposal.event, nexus:proposal.nexus,
turn:proposal.turn, accept:true, expectedRevision:game.coalitionState!.revision});
}
void test('host selects a group preview, clearing readiness, and can restore ordinary rules', () => {
let game = ready(lobby());
assert.equal(viewGame(game, 'at').coalitionPreview, 'off');
const original = structuredClone(game);
assert.throws(() => apply(game,'em',{type:'coalitionPreview',variant:'three'}));
assert.deepEqual(game, original);
assert.throws(() => apply(game,'at',{type:'coalitionPreview',variant:'bad'}));
assert.throws(() => apply(game,'at',{type:'coalitionPreview',variant:'three', extra:true}));
game = apply(game,'at',{type:'coalitionPreview',variant:'three'});
assert.equal(viewGame(game,'em').coalitionPreview,'three');
assert.ok(game.players.every(player => !player.ready));
game = apply(ready(game),'at',{type:'coalitionPreview',variant:'off'});
assert.equal(viewGame(game,'em').coalitionPreview,'off');
assert.ok(game.players.every(player => !player.ready));
game = apply(ready(game),'at',{type:'start'});
assert.equal(game.coalitionState, undefined);
});
void test('each selected Basic group variant starts a genuine game with the intended fresh ledger', () => {
for (const variant of ['three','three-short','unlimited'] as const) {
let game = apply(lobby(),'at',{type:'coalitionPreview',variant});
game = apply(ready(game),'at',{type:'start'});
assert.ok(game.coalitionState);
assert.equal(game.coalitionState.variant, variant === 'three' ? undefined : variant);
assert.deepEqual(game.coalitionState.groups, []);
assert.equal(game.coalitionState.proposal, null);
assert.equal(viewGame(game,'em').coalitionPreview,variant);
}
});
void test('group preview refuses incompatible rules and optional alliance settings at start', () => {
let advanced = apply(lobby(),'at',{type:'coalitionPreview',variant:'three'});
advanced = apply(advanced,'at',{type:'rules',advanced:true});
advanced = ready(advanced);
assert.throws(() => apply(advanced,'at',{type:'start',advancedPreview:true}), /group|alliance/i);
let shortened = apply(lobby(),'at',{type:'coalitionPreview',variant:'three'});
shortened = apply(shortened,'at',{type:'shortAllianceVictory',enabled:true});
shortened = ready(shortened);
assert.throws(() => apply(shortened,'at',{type:'start'}), /group|alliance/i);
for (const action of [{type:'homeworlds',enabled:true}, {type:'techTokens',enabled:true},
{type:'moduleOption',module:'leaderSkills',enabled:true}] as const) {
let configured = apply(lobby(),'at',{type:'coalitionPreview',variant:'three'});
configured = apply(configured,'at',action);
configured = ready(configured);
assert.throws(() => apply(configured,'at',{type:'start'}), /group|alliance/i);
}
let expansion = createGame('GROUPEXP',newPlayer('at','Atreides','atreides'),false,['ix']);
joinGame(expansion,newPlayer('em','Emperor','emperor'));
joinGame(expansion,newPlayer('gu','Guild','guild'));
expansion = apply(expansion,'at',{type:'coalitionPreview',variant:'three'});
expansion = ready(expansion);
assert.throws(() => apply(expansion,'at',{type:'start'}), /group|alliance/i);
let twoSeats = createGame('GROUP2',newPlayer('at','Atreides','atreides'));
joinGame(twoSeats,newPlayer('em','Emperor','emperor'));
twoSeats = apply(twoSeats,'at',{type:'coalitionPreview',variant:'three'});
twoSeats = ready(twoSeats);
assert.throws(() => apply(twoSeats,'at',{type:'start'}), /three|3|group/i);
});
/** Real deal and setup, native first-turn actions, and actual Nexus negotiation.
* Only an unopened original worm card is moved to the top of the Spice deck. */
function groupNexus(): Game {
let game = apply(lobby(),'at',{type:'coalitionPreview',variant:'three-short'});
game = apply(ready(game),'at',{type:'start'});
const actions: ShortAllianceStep[] = [];
game = advanceShortAlliance(game, state => state.status === 'playing' && state.turn === 2 && state.phase === 0, actions);
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 = advanceShortAlliance(game, state => state.nexus && !state.spiceWindow && !state.spiceResolution &&
!state.response && !state.decision && !state.truthtrance && !state.phaseOpening, actions);
game = apply(game,'at',{type:'alliance',target:'em'});
return apply(game,'em',{type:'alliance',target:'at'});
}
void test('an actual started preview reaches group formation, unanimous consent and legal bot proposals', () => {
const original = groupNexus();
const pair = coalitionOf(original.players,original.coalitionState,'at'); assert.ok(pair);
assert.deepEqual(new Set(pair.members),new Set(['at','em']));
let formed: Game | undefined;
for (const difficulty of DIFFICULTIES) {
const view = viewGame(original,'gu'); view.players.find(player => player.id === 'gu')!.bot = difficulty;
const proposal = botActions(view).find(action => action.type === 'coalitionPropose'); assert.ok(proposal,difficulty);
let game = apply(original,'gu',proposal);
game = answer(game,'at'); game = answer(game,'em');
assert.deepEqual(new Set(coalitionOf(game.players,game.coalitionState,'at')!.members),new Set(['at','em','gu']));
assert.equal(game.coalitionState!.proposal,null);
const progress = viewGame(game,'at').victoryProgress.find(row => row.player === 'at'); assert.ok(progress);
assert.equal(progress.target,3);
formed = game;
}
assert.ok(formed);
// Once all original seats finish Nexus, use native controls for the rest of
// the turn. The next Storm opens before another Nexus can change alliances.
for (const player of formed.players)
if (formed.nexus && !formed.ready.includes(player.id)) formed = apply(formed,player.id,{type:'ready'});
const continued = advanceShortAlliance(formed,
state => state.status === 'playing' && state.turn === 3 && state.phase === 0, []);
assert.equal(continued.turn,3);
assert.deepEqual(new Set(coalitionOf(continued.players,continued.coalitionState,'at')!.members),new Set(['at','em','gu']));
});