Prototype unlimited alliance variant
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
37acaeb9d2
commit
9cb65a21f5
23 files changed
+677
-52
No files matched your search
@@ -10,6 +10,8 @@ export type CoalitionNexusView = {
|
||||
groups: readonly CoalitionGroup[];
|
||||
proposal: CoalitionAdmission | null;
|
||||
revision: number;
|
||||
/** Absent preserves the original, at-most-three alliance prototype. */
|
||||
variant?: 'unlimited';
|
||||
};
|
||||
|
||||
export type CoalitionNexusIntent =
|
||||
@@ -28,7 +30,7 @@ export function coalitionNexusOptions(view: CoalitionNexusView): {
|
||||
const ownGroup = view.groups.find(group => group.members.includes(view.me));
|
||||
const occupied = new Set(view.groups.flatMap(group => group.members));
|
||||
const invitations = !seated || view.proposal ? [] : view.groups.flatMap(group =>
|
||||
group.members.length !== 2 ? [] : view.players
|
||||
(group.members.length < 2 || group.members.length >= (view.variant === 'unlimited' ? 6 : 3)) ? [] : view.players
|
||||
.filter(player => !occupied.has(player.id) &&
|
||||
(player.id === view.me || group.members.includes(view.me)))
|
||||
.map(player => ({ groupId: group.id, entrant: player.id, members: group.members })));
|
||||
@@ -57,8 +59,10 @@ export function CoalitionNexus({ view, disabled = false, onIntent }: {
|
||||
const submit = (intent: CoalitionNexusIntent) => { if (!disabled) onIntent(intent); };
|
||||
return <section aria-label="Group alliance negotiations" className="notice min-w-0 space-y-3 break-words">
|
||||
<h3>Group alliance negotiations</h3>
|
||||
<p>All three players must agree before an invitation forms an alliance.
|
||||
The existing pair remains allied while an invitation is pending.</p>
|
||||
{view.variant === 'unlimited' ? <p>Every current member and the invited player must agree.
|
||||
The existing alliance remains in place while an invitation is pending.</p> :
|
||||
<p>All three players must agree before an invitation forms an alliance.
|
||||
The existing pair remains allied while an invitation is pending.</p>}
|
||||
{view.groups.length ? <ul>{view.groups.map(group =>
|
||||
<li key={group.id}>{names(group.members)}</li>)}</ul> : <p>There are no alliances yet.</p>}
|
||||
{proposal ? <div aria-live="polite">
|
||||
@@ -77,7 +81,9 @@ export function CoalitionNexus({ view, disabled = false, onIntent }: {
|
||||
proposal.approvals.includes(view.me) ? 'Withdraw approval and cancel invitation' :
|
||||
'Decline and cancel invitation'}</button>}
|
||||
</div> : <div>
|
||||
{options.invitations.length > 0 && <p>Invite an unallied player to an existing pair, or ask to join a pair.</p>}
|
||||
{options.invitations.length > 0 && <p>{view.variant === 'unlimited'
|
||||
? 'Invite an unallied player to an existing alliance, or ask to join one.'
|
||||
: 'Invite an unallied player to an existing pair, or ask to join a pair.'}</p>}
|
||||
{options.invitations.map(option => <button className="game-action" disabled={disabled}
|
||||
key={JSON.stringify([option.groupId, option.entrant])} onClick={() => submit({
|
||||
kind: 'propose', groupId: option.groupId, entrant: option.entrant, expectedRevision: view.revision,
|
||||
@@ -87,6 +93,8 @@ export function CoalitionNexus({ view, disabled = false, onIntent }: {
|
||||
kind: 'leave', expectedRevision: view.revision,
|
||||
})}>Leave {names(ownGroup!.members)}</button>}
|
||||
{ownGroup?.members.length === 3 && <p>Leaving keeps the other two players allied under the provisional online policy.</p>}
|
||||
{view.variant === 'unlimited' && (ownGroup?.members.length ?? 0) > 3 &&
|
||||
<p>Leaving keeps the other players allied under the provisional online policy.</p>}
|
||||
</div>}
|
||||
</section>;
|
||||
}
|
||||
@@ -306,7 +306,7 @@ export function GameTable({
|
||||
}) {
|
||||
const me = g.players.find((p) => p.id === g.me)!;
|
||||
const groupNexus = g.coalitionNexus;
|
||||
const inGroup = groupNexus?.groups.some(group => group.members.length === 3 && group.members.includes(me.id));
|
||||
const inGroup = groupNexus?.groups.some(group => group.members.length >= 3 && group.members.includes(me.id));
|
||||
const outgoingAlliance = g.allianceOffers[me.id];
|
||||
const outgoingAllianceName = g.players.find((p) => p.id === outgoingAlliance)?.name;
|
||||
const shipmentAvailable = nexusGuildShipmentAvailable(g);
|
||||
@@ -4395,7 +4395,7 @@ export function GameTable({
|
||||
<option value="">Choose a player</option>
|
||||
{g.players
|
||||
.filter((p) => p.id !== me.id && !groupNexus?.groups.some(group =>
|
||||
group.members.length === 3 && group.members.includes(p.id)))
|
||||
group.members.length >= 3 && group.members.includes(p.id)))
|
||||
.map((p) => (
|
||||
<option value={p.id} key={p.id}
|
||||
disabled={!!g.homeworldAllianceBlocks?.[p.id]}>
|
||||
|
||||
+13
-2
@@ -17,6 +17,17 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**Staged unlimited alliances — 29 October. Prototyped / Partial.** An explicit
|
||||
`coalitionState.variant` of `unlimited` permits up to six faction members;
|
||||
absence preserves the existing three-member prototype. Real staged actions
|
||||
cover four-member unanimous admission, three-donor funding, departure and
|
||||
rejoining, a three-stronghold win, Emperor aid, bot admission and actual table
|
||||
controls. Membership and selected faction helper cases cover up to six seats.
|
||||
Fourteen related files pass 120 cases, with an additional pair-target case;
|
||||
typecheck, lint and the production build pass. Live commitments, other profiles,
|
||||
full interactions and lobby entry remain unfinished.
|
||||
[Scope](OPTIONAL_ALLIANCE_VICTORY.md#unlimited-alliance-prototype--29-october-2026).
|
||||
|
||||
**Basic Kull, Richese and mixed current-lot Truthtrance — 29 October. Prototyped / Partial.**
|
||||
Mixed current-fact and ordinary-bid questions can continue through supported
|
||||
Kull and Semuta responses. Special-cache questions, other structured
|
||||
@@ -293,8 +304,8 @@ Connected staged Nexus and shipment controls model unanimous consent, explicit
|
||||
donor allocations and edge-bound pledge authorizations. The engine issues and
|
||||
revokes those authorizations and refunds escrow; special auctions and transport
|
||||
still require integration.
|
||||
The separate printed unlimited-size / three-stronghold variant remains missing;
|
||||
the current group model deliberately caps membership at three. The combined
|
||||
The later staged unlimited prototype above supersedes this earlier three-seat
|
||||
limit; normal lobby access and full interactions remain unfinished. The combined
|
||||
earlier batch passed 189 cases; the shipment batch passed 58 cases. The latest
|
||||
focused funding, Nexus, shipment and Truthtrance union passed 61 cases.
|
||||
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
# Optional three-stronghold alliance victory
|
||||
|
||||
## Unlimited alliance prototype — 29 October 2026
|
||||
|
||||
**Staged Basic prototype, not a selectable game mode.** The optional
|
||||
`unlimited` ledger permits up to the six seated factions in one alliance.
|
||||
An existing alliance or an unallied entrant may propose a new member; every
|
||||
existing member and the entrant must agree. A departure preserves the other
|
||||
members and their original pairwise alliance edges. The table shows the
|
||||
participants and pending approvals; minimal bots can request admission and
|
||||
answer. Named allies can contribute separately to ordinary bids and physical
|
||||
shipments. The staged Guild, Emperor, Fremen, battle preparation and traitor
|
||||
paths use the same group membership. A qualifying alliance needs three distinct
|
||||
strongholds, plus any separately agreed increase; the existing two-controller
|
||||
precedence remains provisional. Other alliances retain their previous targets.
|
||||
|
||||
The [publisher's optional alliance table](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf)
|
||||
pairs any number of allies with three strongholds. Unanimous admission,
|
||||
departure handling, donor-specific escrow and extension of single-ally powers
|
||||
are provisional online procedures, not additional publisher rulings. Tests use
|
||||
an explicitly staged Basic classic-faction ledger, not a normal lobby start.
|
||||
Advanced, expansions, optional modules, live Truthtrance commitments and
|
||||
complete group interactions remain unfinished. No lobby creates this ledger;
|
||||
the prototype does not certify or open a complete rules mode.
|
||||
|
||||
## Group roster and self-funded Guild transport — 29 October 2026
|
||||
|
||||
**Staged Basic prototype, not a selectable game mode.** The public roster
|
||||
@@ -173,8 +196,8 @@ provisional online procedures, not publisher rulings. This scope excludes
|
||||
expansion decks/factions, Advanced, optional modules, live Truthtrance
|
||||
commitments and pledged aid. No lobby creates the group ledger: faction ally
|
||||
benefits, group funding and other interactions must be connected before any
|
||||
player can start the printed larger-alliance option. The separate unlimited
|
||||
alliance variant remains missing.
|
||||
player can start the printed larger-alliance option. The later unlimited
|
||||
prototype above extends this staged groundwork.
|
||||
|
||||
**Prototyped / Partial — 28 October 2026.** The host can select a shorter
|
||||
two-member alliance game in the lobby. With at least three physical
|
||||
@@ -191,6 +214,6 @@ two-member alliance with three strongholds as a shorter game. The
|
||||
preserves that table. This first version keeps the existing four-stronghold
|
||||
target for two physical controllers, including the two-faction-per-person
|
||||
setup; priority between that special setup and this optional table is a
|
||||
provisional interpretation. Larger alliance variants require separate
|
||||
membership and interaction work. Advanced and expansion combinations are not
|
||||
provisional interpretation. Larger alliance variants retain separate
|
||||
membership and interaction work as described above. Advanced and expansion combinations are not
|
||||
certified by the Basic-focused evidence.
|
||||
@@ -1,5 +1,12 @@
|
||||
# Rule decision index
|
||||
|
||||
## Unlimited alliances — 29 October 2026
|
||||
|
||||
The staged Basic prototype models the publisher's any-size alliance and
|
||||
three-stronghold option. Unanimous admission, surviving groups after departure,
|
||||
per-donor funding and application of ally powers are provisional compositions.
|
||||
[Scope](OPTIONAL_ALLIANCE_VICTORY.md#unlimited-alliance-prototype--29-october-2026).
|
||||
|
||||
## Basic Kull, Richese and mixed current-lot answers — 29 October 2026
|
||||
|
||||
Mixed ordinary-lot bid answers now continue through the supported Kull and
|
||||
|
||||
@@ -39,7 +39,7 @@ function currentShipmentAllies(context: ShipmentContext, player: OccupancySeat):
|
||||
id: seat.id, ally: seat.ally ?? null, allySinceTurn: seat.allySinceTurn,
|
||||
}));
|
||||
const groups = coalitionGroups(seats, context.coalitionState);
|
||||
if (groups.some(group => group.members.length === 3) &&
|
||||
if (groups.some(group => group.members.length >= 3) &&
|
||||
(context.advanced || context.players.some(seat => seat.faction === 'ecaz')))
|
||||
throw new Error('Three-member shipment rules require Basic play without Ecaz.');
|
||||
return coalitionAllies(seats, context.coalitionState, player.id);
|
||||
|
||||
@@ -42,7 +42,7 @@ function validateBoardCoalitions(g: BoardContext) {
|
||||
if (g.coalitionState === undefined) return;
|
||||
try {
|
||||
for (const group of coalitionGroups(g.players, g.coalitionState))
|
||||
if (group.members.length === 3 && (g.advanced || g.players.some(player => player.faction === 'ecaz')))
|
||||
if (group.members.length >= 3 && (g.advanced || g.players.some(player => player.faction === 'ecaz')))
|
||||
throw new Error('Three-member board rules require Basic play without Ecaz.');
|
||||
} catch (error) {
|
||||
throw new BoardResolutionError((error as Error).message);
|
||||
|
||||
+7
-1
@@ -3226,6 +3226,12 @@ function policyActions(g: GameView): Action[] {
|
||||
const card = me.hand?.find((c) => c.effect === 'harvester');
|
||||
if (card) return [{ type: 'card', card: card.id }];
|
||||
}
|
||||
if (g.nexus && !g.spiceWindow && !g.ready.includes(me.id) && group?.variant === 'unlimited' &&
|
||||
!group.groups.some(alliance => alliance.members.includes(me.id))) {
|
||||
const alliance = group.groups.find(candidate => candidate.members.length >= 2 && candidate.members.length < 6);
|
||||
if (alliance) return [{type:'coalitionPropose', groupId:alliance.id, entrant:me.id,
|
||||
expectedRevision:group.revision}];
|
||||
}
|
||||
if (g.nexus && !g.spiceWindow && !me.ally &&
|
||||
!group?.groups.some(alliance => alliance.members.includes(me.id))) {
|
||||
const offer = g.players.find(
|
||||
@@ -3238,7 +3244,7 @@ function policyActions(g: GameView): Action[] {
|
||||
const target = g.players.find(
|
||||
(player) => player.id !== me.id &&
|
||||
!player.ally && !g.homeworldAllianceBlocks?.[player.id] &&
|
||||
!group?.groups.some(alliance => alliance.members.length === 3 && alliance.members.includes(player.id)),
|
||||
!group?.groups.some(alliance => alliance.members.length >= 3 && alliance.members.includes(player.id)),
|
||||
);
|
||||
if (target) return [{ type: 'alliance', target: target.id }];
|
||||
}
|
||||
|
||||
+27
-20
@@ -1,4 +1,4 @@
|
||||
/** Foundation for the optional three-member alliance. The engine must still
|
||||
/** Foundation for optional larger alliances. The engine must still
|
||||
* authorize Nexus timing, apply legacy-field updates atomically, and connect
|
||||
* each native alliance benefit before enabling this option in play. */
|
||||
export type CoalitionSeat = { id: string; ally: string | null; allySinceTurn?: number };
|
||||
@@ -20,7 +20,7 @@ export type CoalitionAdmission = {
|
||||
nexus: string;
|
||||
turn: number;
|
||||
groupId: string;
|
||||
/** Snapshot binds the offer to the exact existing pair and its edge age. */
|
||||
/** Snapshot binds the offer to the exact existing group and its edge ages. */
|
||||
original: CoalitionGroup;
|
||||
entrant: string;
|
||||
proposer: string;
|
||||
@@ -28,6 +28,8 @@ export type CoalitionAdmission = {
|
||||
};
|
||||
export type CoalitionState = {
|
||||
version: 1;
|
||||
/** Absent preserves the existing three-member maximum. */
|
||||
variant?: 'unlimited';
|
||||
revision: number;
|
||||
groups: CoalitionGroup[];
|
||||
proposal: CoalitionAdmission | null;
|
||||
@@ -62,8 +64,8 @@ const same = (left: unknown, right: unknown) => JSON.stringify(left) === JSON.st
|
||||
const copy = <T>(value: T): T => structuredClone(value);
|
||||
const legacyId = (members: readonly string[]) => `legacy:${JSON.stringify(sorted(members))}`;
|
||||
|
||||
export function emptyCoalitionState(): CoalitionState {
|
||||
return { version: 1, revision: 0, groups: [], proposal: null, usedEvents: [] };
|
||||
export function emptyCoalitionState(variant?: 'unlimited'): CoalitionState {
|
||||
return { version: 1, ...(variant === undefined ? {} : {variant}), revision: 0, groups: [], proposal: null, usedEvents: [] };
|
||||
}
|
||||
function requireSeats(seats: readonly CoalitionSeat[]): void {
|
||||
check(Array.isArray(seats) && seats.length >= 2 && seats.length <= 6 &&
|
||||
@@ -72,9 +74,9 @@ function requireSeats(seats: readonly CoalitionSeat[]): void {
|
||||
new Set(seats.map(seat => seat.id)).size === seats.length,
|
||||
'Coalitions need two through six distinct seated players.');
|
||||
}
|
||||
function requireGroup(group: CoalitionGroup, seats: readonly CoalitionSeat[]): void {
|
||||
function requireGroup(group: CoalitionGroup, seats: readonly CoalitionSeat[], maximum: number): void {
|
||||
check(record(group) && groupId(group.id) && Array.isArray(group.members) &&
|
||||
(group.members.length === 2 || group.members.length === 3) && group.members.every(id) &&
|
||||
group.members.length >= 2 && group.members.length <= maximum && group.members.every(id) &&
|
||||
new Set(group.members).size === group.members.length && same(group.members, sorted(group.members)) &&
|
||||
group.members.every(member => seats.some(seat => seat.id === member)) && age(group.formedTurn) &&
|
||||
Array.isArray(group.edges), 'Invalid saved coalition.');
|
||||
@@ -93,7 +95,8 @@ function requireGroup(group: CoalitionGroup, seats: readonly CoalitionSeat[]): v
|
||||
check(same(actual, expected), 'A coalition must retain exactly one ordered edge for each pair.');
|
||||
}
|
||||
function requireState(state: CoalitionState): void {
|
||||
check(record(state) && state.version === 1 && Number.isSafeInteger(state.revision) && state.revision >= 0 &&
|
||||
check(record(state) && state.version === 1 && (state.variant === undefined || state.variant === 'unlimited') &&
|
||||
Number.isSafeInteger(state.revision) && state.revision >= 0 &&
|
||||
Array.isArray(state.groups) && Array.isArray(state.usedEvents) && state.usedEvents.every(id) &&
|
||||
new Set(state.usedEvents).size === state.usedEvents.length &&
|
||||
(state.proposal === null || record(state.proposal)), 'Invalid saved coalition state.');
|
||||
@@ -108,13 +111,13 @@ export function coalitionGroups(seats: readonly CoalitionSeat[], value?: Coaliti
|
||||
requireState(state);
|
||||
const seen = new Set<string>(), ids = new Set<string>();
|
||||
for (const group of state.groups) {
|
||||
requireGroup(group, seats);
|
||||
requireGroup(group, seats, state.variant === 'unlimited' ? 6 : 3);
|
||||
check(!ids.has(group.id) && group.members.every(member => !seen.has(member)),
|
||||
'A seat or coalition ID cannot belong to multiple groups.');
|
||||
ids.add(group.id); group.members.forEach(member => seen.add(member));
|
||||
if (group.members.length === 3) {
|
||||
if (group.members.length >= 3) {
|
||||
check(group.members.every(member => seats.find(seat => seat.id === member)!.ally === null),
|
||||
'Three-member groups cannot use a fictitious legacy pair.');
|
||||
'Larger groups cannot use a fictitious legacy pair.');
|
||||
} else {
|
||||
const [a, b] = group.members;
|
||||
check(seats.find(seat => seat.id === a)!.ally === b && seats.find(seat => seat.id === b)!.ally === a,
|
||||
@@ -166,10 +169,10 @@ export function coalitionGroups(seats: readonly CoalitionSeat[], value?: Coaliti
|
||||
offer.approvals.every(id) && new Set(offer.approvals).size === offer.approvals.length &&
|
||||
state.usedEvents.includes(offer.event), 'Invalid saved coalition invitation.');
|
||||
const group = groups.find(candidate => candidate.id === offer.groupId);
|
||||
check(group && group.members.length === 2 && same(group, offer.original) &&
|
||||
check(group && group.members.length < (state.variant === 'unlimited' ? 6 : 3) && same(group, offer.original) &&
|
||||
seats.some(seat => seat.id === offer.entrant) &&
|
||||
!groups.some(candidate => candidate.members.includes(offer.entrant)),
|
||||
'The saved invitation no longer matches its pair or entrant.');
|
||||
'The saved invitation no longer matches its group or entrant.');
|
||||
const participants = [...group.members, offer.entrant];
|
||||
check(participants.includes(offer.proposer) && offer.approvals.includes(offer.proposer) &&
|
||||
offer.approvals.every(actor => participants.includes(actor)) && offer.approvals.length < participants.length &&
|
||||
@@ -213,7 +216,7 @@ function unchanged(state: CoalitionState): CoalitionTransition {
|
||||
return {state, allyUpdates: {}, allySinceUpdates: {}};
|
||||
}
|
||||
|
||||
/** A proposer is either member of the pair or the free entrant; its offer is
|
||||
/** A proposer is either member of the group or the free entrant; its offer is
|
||||
* its explicit consent. A later decline by any participant cancels the offer. */
|
||||
export function beginCoalitionAdmission(seats: readonly CoalitionSeat[], value: CoalitionState | undefined,
|
||||
request: CoalitionAdmissionRequest): CoalitionTransition {
|
||||
@@ -222,19 +225,19 @@ export function beginCoalitionAdmission(seats: readonly CoalitionSeat[], value:
|
||||
const {state, groups} = currentState(seats, value, request.expectedRevision);
|
||||
check(state.proposal === null && !state.usedEvents.includes(request.event), 'Finish the current invitation and use a new event.');
|
||||
const group = groups.find(candidate => candidate.id === request.groupId);
|
||||
check(group && group.members.length === 2 && seats.some(seat => seat.id === request.entrant) &&
|
||||
check(group && group.members.length < (state.variant === 'unlimited' ? 6 : 3) && seats.some(seat => seat.id === request.entrant) &&
|
||||
!groups.some(candidate => candidate.members.includes(request.entrant)) &&
|
||||
(group.members.includes(request.proposer) || request.proposer === request.entrant) &&
|
||||
(group.formedTurn === null || group.formedTurn <= request.turn) &&
|
||||
group.edges.every(edge => (edge.formedTurn === null || edge.formedTurn <= request.turn) &&
|
||||
(!edge.legacySince || edge.legacySince.every(date => date === null || date <= request.turn))),
|
||||
'Invite one unallied seated player to an existing pair.');
|
||||
'Invite one unallied seated player to an eligible existing alliance.');
|
||||
return unchanged({ ...copy(state), revision: state.revision + 1, usedEvents: [...state.usedEvents, request.event],
|
||||
proposal: {event: request.event, nexus: request.nexus, turn: request.turn, groupId: group.id,
|
||||
original: copy(group), entrant: request.entrant, proposer: request.proposer, approvals: [request.proposer]} });
|
||||
}
|
||||
|
||||
/** Every current pair member and the entrant must explicitly approve. */
|
||||
/** Every current group member and the entrant must explicitly approve. */
|
||||
export function answerCoalitionAdmission(seats: readonly CoalitionSeat[], value: CoalitionState | undefined,
|
||||
answer: CoalitionAdmissionAnswer): CoalitionTransition {
|
||||
check(record(answer) && id(answer.event) && id(answer.nexus) && turn(answer.turn) &&
|
||||
@@ -246,9 +249,9 @@ export function answerCoalitionAdmission(seats: readonly CoalitionSeat[], value:
|
||||
offer.approvals.every(id) && new Set(offer.approvals).size === offer.approvals.length &&
|
||||
state.usedEvents.includes(offer.event), 'The saved coalition invitation is invalid.');
|
||||
const group = groups.find(candidate => candidate.id === offer.groupId);
|
||||
check(group && group.members.length === 2 && same(group, offer.original) &&
|
||||
check(group && group.members.length < (state.variant === 'unlimited' ? 6 : 3) && same(group, offer.original) &&
|
||||
!groups.some(candidate => candidate.members.includes(offer.entrant)) &&
|
||||
seats.some(seat => seat.id === offer.entrant), 'The original pair or entrant changed.');
|
||||
seats.some(seat => seat.id === offer.entrant), 'The original group or entrant changed.');
|
||||
const participants = [...group.members, offer.entrant];
|
||||
check(participants.includes(offer.proposer) && offer.approvals.includes(offer.proposer) &&
|
||||
offer.approvals.every(actor => participants.includes(actor)) &&
|
||||
@@ -273,8 +276,8 @@ export function answerCoalitionAdmission(seats: readonly CoalitionSeat[], value:
|
||||
allyUpdates: Object.fromEntries(members.map(member => [member, null])), allySinceUpdates: {}};
|
||||
}
|
||||
|
||||
/** Provisional online policy: the departing member leaves; the remaining pair
|
||||
* stays allied, preserving that pair's original edge date and group identity. */
|
||||
/** Provisional online policy: remaining members stay allied, preserving each
|
||||
* surviving edge and group identity. A pair restores its legacy scalar fields. */
|
||||
export function leaveCoalition(seats: readonly CoalitionSeat[], value: CoalitionState | undefined,
|
||||
request: {actor: string; expectedRevision: number}): CoalitionTransition {
|
||||
check(record(request) && id(request.actor), 'Choose the departing player.');
|
||||
@@ -292,6 +295,10 @@ export function leaveCoalition(seats: readonly CoalitionSeat[], value: Coalition
|
||||
allyEntries.push([remaining[0], remaining[1]], [remaining[1], remaining[0]]);
|
||||
const retainedDates = retained.legacySince ?? [retained.formedTurn, retained.formedTurn];
|
||||
ageEntries.push([remaining[0], retainedDates[0]], [remaining[1], retainedDates[1]]);
|
||||
} else if (remaining.length >= 3) {
|
||||
const edges = group.edges.filter(edge => edge.members.every(member => remaining.includes(member))).map(copy);
|
||||
nextGroups.push({id: group.id, members: remaining, formedTurn: group.formedTurn, edges});
|
||||
for (const member of remaining) allyEntries.push([member, null]);
|
||||
} else for (const member of remaining) { allyEntries.push([member, null]); ageEntries.push([member, null]); }
|
||||
return {state: {...copy(state), revision: state.revision + 1, groups: nextGroups},
|
||||
allyUpdates: Object.fromEntries(allyEntries), allySinceUpdates: Object.fromEntries(ageEntries)};
|
||||
|
||||
@@ -45,7 +45,7 @@ export function coalitionBattleBeneficiary(
|
||||
const seats = game.players.map(player => ({id: player.id, ally: player.ally ?? null,
|
||||
...(player.allySinceTurn !== undefined ? {allySinceTurn: player.allySinceTurn} : {})}));
|
||||
const group = coalitionOf(seats, game.coalitionState, ownerId);
|
||||
if (group?.members.length === 3) {
|
||||
if (group && group.members.length >= 3) {
|
||||
const members = combatants.filter(player => group.members.includes(player));
|
||||
return members.length === 1 ? members[0] : null;
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ function threatened(g: Game, owner: string, territory: string): string[] | null
|
||||
if (error instanceof CoalitionAllianceError) throw new CoalitionWormProtectionError(error.message);
|
||||
throw error;
|
||||
}
|
||||
if (!group || group.members.length !== 3) return null;
|
||||
if (!group || group.members.length < 3) return null;
|
||||
requireProtection(Array.isArray(g.order) && g.order.length === g.players.length &&
|
||||
new Set(g.order).size === g.order.length && g.order.every(seat => g.players.some(player => player.id === seat)),
|
||||
'Group worm protection requires the current complete storm order.');
|
||||
@@ -75,7 +75,7 @@ export function validateCoalitionWormProtection(g: Game, frame: CoalitionWormPro
|
||||
requireProtection(object(frame) && Object.keys(frame).sort().join(',') === 'event,order,outcomes,owner,stage,territory,turn' &&
|
||||
id(frame.event) && id(frame.owner) && id(frame.territory) && validTerritory(frame.territory) &&
|
||||
Number.isSafeInteger(frame.turn) && frame.turn === g.turn && Array.isArray(frame.order) &&
|
||||
frame.order.length > 0 && frame.order.length <= 2 && frame.order.every(id) &&
|
||||
frame.order.length > 0 && frame.order.length <= 5 && frame.order.every(id) &&
|
||||
new Set(frame.order).size === frame.order.length && Array.isArray(frame.outcomes) &&
|
||||
['decision', 'response', 'complete'].includes(frame.stage),
|
||||
'The group worm protection has lost its original occurrence.');
|
||||
|
||||
+6
-5
@@ -16112,7 +16112,7 @@ function coalitionFundingAvailable(g: Game, p: Player, committedBid = false): bo
|
||||
g.revivalPromises, g.leaderRevivalPromises, g.revivalExpressionPromises,
|
||||
g.bribePromises, g.bribeExpressionPromises].some(unsettledPromises))) return false;
|
||||
const group = coalitionRule(() => coalitionOf(g.players, g.coalitionState, p.id));
|
||||
return group?.members.length === 3;
|
||||
return !!group && group.members.length >= 3;
|
||||
}
|
||||
function coalitionEmperorGroup(g: Game, p: Player) {
|
||||
if (!g.coalitionState || p.faction !== 'emperor' || g.status !== 'playing' || g.advanced ||
|
||||
@@ -16120,7 +16120,7 @@ function coalitionEmperorGroup(g: Game, p: Player) {
|
||||
g.strongholdCards || g.ecazTreachery || g.discoveryEnabled ||
|
||||
!g.players.every(seat => ['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen'].includes(seat.faction))) return null;
|
||||
const group = coalitionRule(() => coalitionOf(g.players, g.coalitionState, p.id));
|
||||
return group?.members.length === 3 ? group : null;
|
||||
return group && group.members.length >= 3 ? group : null;
|
||||
}
|
||||
function guildGroupAlly(g: Game, p: Player): boolean {
|
||||
return coalitionRule(() => groupGuildShipmentRate(g.players, p.id, g.coalitionState));
|
||||
@@ -44546,14 +44546,14 @@ function applyActionInner(
|
||||
g.allianceOffers = Object.fromEntries(Object.entries(g.allianceOffers)
|
||||
.filter(([from, to]) => !members.has(from) && !members.has(to)));
|
||||
}
|
||||
log(g, formed ? 'All three players agreed to the group alliance.'
|
||||
log(g, formed ? 'All participants agreed to the group alliance.'
|
||||
: action.accept ? `${p.name} approved the group invitation.`
|
||||
: `${p.name} declined the group invitation; the existing alliance remains.`);
|
||||
} else {
|
||||
const result = coalitionRule(() => leaveCoalition(g.players, g.coalitionState,
|
||||
{actor: id, expectedRevision: revision}));
|
||||
applyCoalitionTransition(g, result);
|
||||
log(g, `${p.name} left the group alliance. Remaining partners stay allied if two remain.`);
|
||||
log(g, `${p.name} left the group alliance. Remaining partners stay allied if at least two remain.`);
|
||||
}
|
||||
g.ready = [];
|
||||
return g;
|
||||
@@ -44568,7 +44568,7 @@ function applyActionInner(
|
||||
requireRule(!g.coalitionState.proposal, 'Resolve the group invitation before changing alliances.');
|
||||
const own = coalitionRule(() => coalitionOf(g.players, g.coalitionState, id));
|
||||
const otherGroup = target ? coalitionRule(() => coalitionOf(g.players, g.coalitionState, target)) : null;
|
||||
requireRule(own?.members.length !== 3 && otherGroup?.members.length !== 3,
|
||||
requireRule(!(own && own.members.length >= 3) && !(otherGroup && otherGroup.members.length >= 3),
|
||||
'Group members must use the group alliance controls.');
|
||||
if (target === null && own && g.coalitionState.groups.some(group => group.id === own.id)) {
|
||||
requireRule(coalitionNexusAvailable(g), 'Group negotiations are unavailable in this Nexus.');
|
||||
@@ -46825,6 +46825,7 @@ export function viewGame(state: Game, id: string, now?: number) {
|
||||
groups: coalitionRule(() => coalitionGroups(g.players, g.coalitionState)),
|
||||
proposal: g.coalitionState!.proposal,
|
||||
revision: g.coalitionState!.revision,
|
||||
...(g.coalitionState!.variant ? {variant: g.coalitionState!.variant} : {}),
|
||||
} : null,
|
||||
homeworldAllianceBlocks: Object.fromEntries(g.players.filter((p) => p.id !== id)
|
||||
.flatMap((p) => { const reason = homeworldAllianceReason(g, id, p.id); return reason ? [[p.id, reason]] : []; })),
|
||||
|
||||
@@ -35,7 +35,7 @@ function groupRecipients(g: FremenRevivalGrantContext, owner: string): string[]
|
||||
return null;
|
||||
try {
|
||||
const group = coalitionOf(g.players, g.coalitionState, owner);
|
||||
return group?.members.length === 3 ? group.members.filter(member => member !== owner) : null;
|
||||
return group && group.members.length >= 3 ? group.members.filter(member => member !== owner) : null;
|
||||
} catch (error) {
|
||||
if (error instanceof CoalitionAllianceError) throw new FremenRevivalGrantError(error.message);
|
||||
throw error;
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ export function territoryEntryBlock(
|
||||
const seats = players.map(p => ({ id: p.id, ally: p.ally ?? null, allySinceTurn: p.allySinceTurn }));
|
||||
if (coalitionState !== undefined) {
|
||||
const groups = coalitionGroups(seats, coalitionState);
|
||||
if (players.some(player => player.faction === 'ecaz') && groups.some(group => group.members.length === 3))
|
||||
if (players.some(player => player.faction === 'ecaz') && groups.some(group => group.members.length >= 3))
|
||||
throw new Error('Three-member board rules do not yet support Ecaz.');
|
||||
}
|
||||
if (advisors) return null;
|
||||
|
||||
+2
-2
@@ -4501,11 +4501,11 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
example:
|
||||
'At the final turn with neither Guild nor Fremen, two players each hold two qualifying strongholds and a third holds one. The two leaders of the count share the win.',
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'A host-selectable shorter two-member target shares victory calculation. Unexposed staged Basic groups connect Nexus, selected funding including purchased Karama shipment prices, Guild transport, Emperor and Fremen aid, Fremen worm protection, Voice, Prescience and Harkonnen traitor support; other group benefits remain open.', evidence: ['game/alliance-victory-option.ts', 'game/victory-progress.ts', 'game/victory-quote.ts', 'game/engine.ts', 'game/coalition-worm-protection.ts', 'game/coalition-battle-preparation.ts', 'game/traitor-declarations.ts', 'game/terminal-cancellation.ts'] },
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'A host-selectable shorter two-member target shares victory calculation. Unexposed staged Basic groups connect Nexus, selected funding including purchased Karama shipment prices, Guild transport, Emperor and Fremen aid, Fremen worm protection, Voice, Prescience and Harkonnen traitor support. An explicit staged unlimited variant extends membership to six and uses the three-stronghold target; other group interactions remain open.', evidence: ['game/alliance-victory-option.ts', 'game/victory-progress.ts', 'game/victory-quote.ts', 'game/engine.ts', 'game/coalition-worm-protection.ts', 'game/coalition-battle-preparation.ts', 'game/traitor-declarations.ts', 'game/terminal-cancellation.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'The lobby offers the pair option. A staged group Emperor can choose either ally for spice and extra revival; group games cannot yet be started from the lobby.', evidence: ['components/game-table.tsx', 'components/coalition-emperor-aid.tsx', 'tests/short-alliance-victory-controls.test.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Legal alliance and victory paths use the shared target; staged group Emperor bots can offer a targeted revival. Strategic tuning remains deferred.', evidence: ['game/bots.ts', 'game/victory-progress.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'The printed option and provisional two-controller precedence are recorded.', evidence: ['docs/OPTIONAL_ALLIANCE_VICTORY.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused pair and staged group evidence includes real Nexus actions, funding after staged settled Truthtrance records, Guild transport, purchased-rate funding, Emperor and Fremen aid, worm protection, battle preparation and allied traitor votes; larger alliances and full combinations remain open.', evidence: ['tests/alliance-victory-option.test.ts', 'tests/short-alliance-victory-runtime.test.ts', 'tests/coalition-nexus-engine.test.ts', 'tests/coalition-shipment-funding.test.ts', 'tests/coalition-bid-funding.test.ts', 'tests/coalition-guild-shipment.test.ts', 'tests/coalition-guild-transport.test.ts', 'tests/coalition-fremen-worm.test.ts', 'tests/coalition-emperor-aid.test.ts', 'tests/coalition-fremen-revival.test.ts', 'tests/coalition-battle-preparation.test.ts', 'tests/coalition-battle-power-question.test.ts', 'tests/coalition-harkonnen-traitor.test.ts', 'tests/coalition-traitor-declarations.test.ts'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused pair and staged group evidence includes real Nexus actions, four-member unlimited admission, funding and victory, staged larger-group faction helpers, funding after settled Truthtrance records, Guild transport, Emperor and Fremen aid, worm protection, battle preparation and allied traitor votes; complete group play and combinations remain open.', evidence: ['tests/alliance-victory-option.test.ts', 'tests/short-alliance-victory-runtime.test.ts', 'tests/coalition-unlimited-engine.test.ts', 'tests/coalition-unlimited-powers.test.ts', 'tests/coalition-unlimited-emperor.test.ts', 'tests/coalition-nexus-engine.test.ts', 'tests/coalition-shipment-funding.test.ts', 'tests/coalition-bid-funding.test.ts', 'tests/coalition-guild-shipment.test.ts', 'tests/coalition-guild-transport.test.ts', 'tests/coalition-fremen-worm.test.ts', 'tests/coalition-emperor-aid.test.ts', 'tests/coalition-fremen-revival.test.ts', 'tests/coalition-battle-preparation.test.ts', 'tests/coalition-battle-power-question.test.ts', 'tests/coalition-harkonnen-traitor.test.ts', 'tests/coalition-traitor-declarations.test.ts'] },
|
||||
],
|
||||
related: [
|
||||
'mentat',
|
||||
|
||||
@@ -144,7 +144,7 @@ export function validateTerminalCancellation(
|
||||
if (!allied && g.coalitionState) {
|
||||
try {
|
||||
const group = coalitionOf(g.players, g.coalitionState, owner.id);
|
||||
allied = !!group && group.members.length === 3 && !!response.recipient &&
|
||||
allied = !!group && group.members.length >= 3 && !!response.recipient &&
|
||||
group.members.includes(response.recipient);
|
||||
} catch (error) {
|
||||
if (error instanceof CoalitionAllianceError) throw new TerminalCancellationError(error.message);
|
||||
|
||||
@@ -54,9 +54,9 @@ function canonicalSides(order: readonly string[], sides: readonly (readonly stri
|
||||
requireContinuation(Array.isArray(sides), 'Retain the qualifying victory sides.');
|
||||
const unique: string[][] = [];
|
||||
for (const side of sides) {
|
||||
requireContinuation(ids(side) && side.length >= 1 && side.length <= 3 &&
|
||||
requireContinuation(ids(side) && side.length >= 1 && side.length <= order.length &&
|
||||
new Set(side).size === side.length && side.every(player => order.includes(player)),
|
||||
'Every qualifying side must contain one through three distinct seated players.');
|
||||
'Every qualifying side must contain distinct seated players.');
|
||||
const members = order.filter(player => side.includes(player));
|
||||
if (!unique.some(existing => same(existing, members))) unique.push(members);
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ export function victoryMembers(g: BoardContext, player: string): string[] {
|
||||
if (g.coalitionState === undefined)
|
||||
return g.order.filter(member => member === player || member === seat.ally);
|
||||
const groups = coalitionGroups(g.players, g.coalitionState);
|
||||
requireProgress(!groups.some(group => group.members.length === 3 &&
|
||||
requireProgress(!groups.some(group => group.members.length >= 3 &&
|
||||
(g.advanced || g.players.some(candidate => candidate.faction === 'ecaz'))),
|
||||
'Three-member victory rules require Basic play without Ecaz.');
|
||||
const group = groups.find(candidate => candidate.members.includes(player));
|
||||
@@ -154,7 +154,7 @@ export function strongholdProgress(
|
||||
);
|
||||
const target = allianceVictoryTarget(
|
||||
players.map((player) => player.id), g.twoPlayerControllers, members.length > 1,
|
||||
members.length === 3 ? false : g.shortAllianceVictory,
|
||||
g.coalitionState?.variant === 'unlimited' ? true : members.length >= 3 ? false : g.shortAllianceVictory,
|
||||
) + extra;
|
||||
return {
|
||||
player: id,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { CoalitionNexus, coalitionNexusOptions, type CoalitionNexusView } from '../components/coalition-nexus';
|
||||
import type { CoalitionGroup } from '../game/coalition-alliance';
|
||||
|
||||
const players = ['a', 'b', 'c', 'd', 'e', 'f'].map(id => ({id, name: id.toUpperCase()}));
|
||||
function group(members: string[]): CoalitionGroup {
|
||||
const edges: CoalitionGroup['edges'] = [];
|
||||
for (let left = 0; left < members.length; left++)
|
||||
for (let right = left + 1; right < members.length; right++)
|
||||
edges.push({members: [members[left], members[right]], formedTurn: 1});
|
||||
return {id: 'group', members, edges, formedTurn: 1};
|
||||
}
|
||||
function view(me: string, members: string[], unlimited = true): CoalitionNexusView {
|
||||
return {me, groups: [group(members)], players, proposal: null, revision: 7,
|
||||
...(unlimited ? {variant: 'unlimited' as const} : {})};
|
||||
}
|
||||
function render(value: CoalitionNexusView, disabled = false) {
|
||||
return renderToStaticMarkup(createElement(CoalitionNexus, {view: value, disabled, onIntent() {}}));
|
||||
}
|
||||
|
||||
void test('unlimited membership offers entry only to a member or the unallied entrant', () => {
|
||||
const member = view('a', ['a', 'b', 'c']);
|
||||
assert.deepEqual(coalitionNexusOptions(member).invitations.map(row => row.entrant), ['d', 'e', 'f']);
|
||||
assert.match(render(member), /Invite D to A, B, C/);
|
||||
const entrant = view('d', ['a', 'b', 'c']);
|
||||
assert.deepEqual(coalitionNexusOptions(entrant).invitations.map(row => row.entrant), ['d']);
|
||||
assert.match(render(entrant), /Ask to join A, B, C/);
|
||||
assert.deepEqual(coalitionNexusOptions(view('missing', ['a', 'b', 'c'])).invitations, []);
|
||||
assert.deepEqual(coalitionNexusOptions(view('a', ['a', 'b', 'c'], false)).invitations, [],
|
||||
'An older three-member prototype must not admit a fourth player.');
|
||||
});
|
||||
|
||||
void test('unlimited invitations allow a sixth player but never a seventh', () => {
|
||||
const five = view('a', ['a', 'b', 'c', 'd', 'e']);
|
||||
assert.deepEqual(coalitionNexusOptions(five).invitations.map(row => row.entrant), ['f']);
|
||||
assert.match(render(five), /Invite F to A, B, C, D, E/);
|
||||
assert.match(render(five), /Leaving keeps the other players allied/);
|
||||
const six = view('a', ['a', 'b', 'c', 'd', 'e', 'f']);
|
||||
assert.deepEqual(coalitionNexusOptions(six).invitations, []);
|
||||
assert.match(render(six), /Leave A, B, C, D, E, F/);
|
||||
assert.match(render(six, true), /disabled=""[^>]*>Leave A, B, C, D, E, F/);
|
||||
});
|
||||
|
||||
void test('every existing member and the entrant can answer one pending group invitation', () => {
|
||||
const original = group(['a', 'b', 'c']);
|
||||
const value = view('d', original.members);
|
||||
value.proposal = {event: 'event', nexus: 'nexus', turn: 2, groupId: original.id,
|
||||
original, entrant: 'd', proposer: 'a', approvals: ['a', 'b']};
|
||||
assert.equal(coalitionNexusOptions(value).mayAccept, true);
|
||||
assert.match(render(value), /Every current member and the invited player must agree/);
|
||||
assert.match(render(value), /Approved: A, B/);
|
||||
assert.match(render(value), /Waiting for: C, D/);
|
||||
assert.match(render(value), /Accept invitation/);
|
||||
value.me = 'b';
|
||||
assert.equal(coalitionNexusOptions(value).mayAccept, false);
|
||||
assert.match(render(value), /Withdraw approval and cancel invitation/);
|
||||
value.me = 'e';
|
||||
assert.equal(coalitionNexusOptions(value).mayAccept, false);
|
||||
assert.equal(coalitionNexusOptions(value).mayDecline, false);
|
||||
assert.doesNotMatch(render(value), /Accept invitation|cancel invitation/);
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
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 send = (game: Game, id: string, action: Action) => applyAction(game, id, action);
|
||||
const seat = (game: Game, id: string) => game.players.find(player => player.id === id)!;
|
||||
|
||||
/** The lobby cannot start this variant yet. Stage a Basic Nexus; the pair,
|
||||
* both admissions and every later decision are actual authoritative actions. */
|
||||
function fixture(phase = 4) {
|
||||
let game = createGame('UNLIMITEMP', newPlayer('e', 'Emperor', 'emperor'), false);
|
||||
game.players.push(newPlayer('a', 'Atreides', 'atreides'), newPlayer('h', 'Harkonnen', 'harkonnen'),
|
||||
newPlayer('g', 'Guild', 'guild'), newPlayer('f', 'Fremen', 'fremen'));
|
||||
Object.assign(game, {status:'playing', phase:1, turn:2, nexus:true,
|
||||
order:['e','a','h','g','f'], ready:[], storm:18, deck:baseDeck(),
|
||||
response:null, decision:null, phaseOpening:null});
|
||||
game.coalitionState = emptyCoalitionState('unlimited');
|
||||
game = send(game, 'e', {type:'alliance', target:'a'});
|
||||
game = send(game, 'a', {type:'alliance', target:'e'});
|
||||
for (const entrant of ['h', 'g']) {
|
||||
const group = coalitionOf(game.players, game.coalitionState, 'e'); assert.ok(group);
|
||||
game = send(game, 'e', {type:'coalitionPropose', groupId:group.id, entrant,
|
||||
expectedRevision:game.coalitionState!.revision});
|
||||
for (const id of [...group.members.filter(member => member !== 'e'), entrant]) {
|
||||
const proposal = game.coalitionState!.proposal!;
|
||||
game = send(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, 'e')!.members, ['a','e','g','h']);
|
||||
Object.assign(game, {phase, nexus:false, ready:[], response:null, decision:null});
|
||||
for (const player of game.players) {
|
||||
player.forces = {}; player.reserves = 20; player.tanks = 0; player.spice = 10;
|
||||
player.hand = []; player.traitors = []; player.traitorChoices = [];
|
||||
}
|
||||
seat(game, 'e').spice = 20;
|
||||
for (const id of ['a','g','h']) { seat(game,id).reserves = 16; seat(game,id).tanks = 4; }
|
||||
const index = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0);
|
||||
const [karama] = game.deck.splice(index, 1); seat(game, 'f').hand.push(karama);
|
||||
return {game, karama};
|
||||
}
|
||||
function passResponses(game: Game): Game {
|
||||
for (let count = 0; game.response && count < 30; count++) {
|
||||
const next = game.players.find(player => !game.response!.passed.includes(player.id));
|
||||
assert.ok(next);
|
||||
game = send(game, next.id, {type:'passResponse'});
|
||||
}
|
||||
assert.equal(game.response ?? null, null);
|
||||
return game;
|
||||
}
|
||||
function conserved(game: Game) {
|
||||
for (const player of game.players)
|
||||
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((a,b) => a+b,0), 20);
|
||||
}
|
||||
|
||||
void test('four-member Emperor gifts reach every selected ally through the native response', () => {
|
||||
let {game} = fixture(3);
|
||||
assert.deepEqual(viewGame(game, 'e').coalitionEmperor?.allies.sort(), ['a','g','h']);
|
||||
for (const target of ['a','h','g']) {
|
||||
const pending = send(game, 'e', {type:'emperorGift', target, amount:2});
|
||||
assert.equal(pending.response?.kind, 'emperorGift');
|
||||
assert.equal(pending.response?.recipient, target);
|
||||
game = passResponses(pending);
|
||||
assert.equal(seat(game,target).spice, 12);
|
||||
}
|
||||
assert.equal(seat(game,'e').spice,14);
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => send(game,'e',{type:'emperorGift', target:'f', amount:1}));
|
||||
assert.deepEqual(game,before);
|
||||
conserved(game);
|
||||
});
|
||||
|
||||
for (const type of ['emperorGift','emperorRevival'] as const)
|
||||
void test(`Karama cancels a four-member Emperor ${type} for the fourth member`, () => {
|
||||
const initial = fixture();
|
||||
let game = send(initial.game, 'e', {type, target:'g', amount:2, elite:0});
|
||||
assert.equal(game.response?.kind, type);
|
||||
assert.equal(game.response?.recipient,'g');
|
||||
game = send(game,'f',{type:'card',mode:'cancel',card:initial.karama.id});
|
||||
game = passResponses(game);
|
||||
assert.equal(seat(game,'e').spice,20);
|
||||
assert.equal(seat(game,'g').spice,10);
|
||||
assert.equal(seat(game,'g').tanks,4);
|
||||
assert.equal(game.emperorExtra.g ?? 0,0);
|
||||
assert.equal(game.discard.filter(card => card.id === initial.karama.id).length,1);
|
||||
// Cancellation must not remove the Emperor's ability to aid other members.
|
||||
game = passResponses(send(game,'e',{type, target:'h', amount:2, elite:0}));
|
||||
assert.equal(seat(game,'e').spice,type === 'emperorGift' ? 18 : 16);
|
||||
if (type === 'emperorGift') assert.equal(seat(game,'h').spice,12);
|
||||
else { assert.equal(seat(game,'h').tanks,2); assert.equal(game.emperorExtra.h,2); }
|
||||
conserved(game);
|
||||
});
|
||||
|
||||
void test('four-member Emperor revivals use separate recipient allowances and the Emperor wallet', () => {
|
||||
let {game} = fixture();
|
||||
for (const target of ['a','h','g']) {
|
||||
const pending = send(game,'e',{type:'emperorRevival',target,amount:3,elite:0});
|
||||
assert.equal(pending.response?.recipient,target);
|
||||
game = passResponses(pending);
|
||||
assert.equal(seat(game,target).tanks,1);
|
||||
assert.equal(seat(game,target).reserves,19);
|
||||
assert.equal(game.emperorExtra[target],3);
|
||||
}
|
||||
assert.equal(seat(game,'e').spice,2);
|
||||
conserved(game);
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
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 { baseDeck } from '../game/cards';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance';
|
||||
import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { strongholdProgress } from '../game/victory-progress';
|
||||
import { quoteVictory } from '../game/victory-quote';
|
||||
import { createVictoryContinuation, voteVictoryContinuation } from '../game/victory-continuation';
|
||||
|
||||
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();
|
||||
|
||||
const send = (game: Game, id: string, action: Action) => applyAction(game,id,action);
|
||||
const seat = (game: Game, id: string) => game.players.find(player => player.id === id)!;
|
||||
|
||||
/** Staged Basic Nexus and unexposed unlimited ledger; all pair and group
|
||||
* negotiations are real actions. The board and later phase boundaries are
|
||||
* deliberately staged in their respective tests. */
|
||||
function pair(variant: 'unlimited' | 'legacy' = 'unlimited'): Game {
|
||||
let game = createGame('UNLIMITED',newPlayer('a','Atreides','atreides'),false);
|
||||
game.players.push(newPlayer('b','Emperor','emperor'),newPlayer('c','Fremen','fremen'),
|
||||
newPlayer('d','Harkonnen','harkonnen'),newPlayer('e','Guild','guild'));
|
||||
Object.assign(game,{status:'playing',phase:1,turn:2,nexus:true,storm:18,
|
||||
order:['a','b','c','d','e'],ready:[],deck:baseDeck()});
|
||||
game.coalitionState = emptyCoalitionState(variant === 'unlimited' ? variant : undefined);
|
||||
game = send(game,'a',{type:'alliance',target:'b'});
|
||||
return send(game,'b',{type:'alliance',target:'a'});
|
||||
}
|
||||
function propose(game:Game, entrant:string):Game {
|
||||
const group=coalitionOf(game.players,game.coalitionState,'a'); assert.ok(group);
|
||||
return send(game,'a',{type:'coalitionPropose',groupId:group.id,entrant,
|
||||
expectedRevision:game.coalitionState!.revision});
|
||||
}
|
||||
function answer(game:Game, id:string, accept=true):Game {
|
||||
const offer=game.coalitionState!.proposal; assert.ok(offer);
|
||||
return send(game,id,{type:'coalitionAnswer',event:offer.event,nexus:offer.nexus,
|
||||
turn:offer.turn,accept,expectedRevision:game.coalitionState!.revision});
|
||||
}
|
||||
function add(game:Game,entrant:string):Game {
|
||||
game=propose(game,entrant);
|
||||
const voters=[...game.coalitionState!.proposal!.original.members.filter(id=>id!=='a'),entrant];
|
||||
for(const id of voters) game=answer(game,id);
|
||||
return game;
|
||||
}
|
||||
function four():Game {
|
||||
return add(add(pair(),'c'),'d');
|
||||
}
|
||||
function movement(game:Game):Game {
|
||||
Object.assign(game,{phase:5,nexus:false,active:'a',ready:[],movementRemaining:['a','b','c','d','e'],
|
||||
guildTimingGranted:false,guildTimingLocked:false});
|
||||
for(const p of game.players) {
|
||||
p.forces={}; p.reserves=20; p.tanks=0; p.spice=10;
|
||||
p.hand=[]; p.traitors=[]; p.traitorChoices=[]; p.shipped=false; p.moved=0;
|
||||
}
|
||||
return game;
|
||||
}
|
||||
function pledge(game:Game,id:string,amount:number):Game {
|
||||
return send(game,id,{type:'coalitionPledge',recipient:'a',amount});
|
||||
}
|
||||
function remaining(game:Game,donor:string):number {
|
||||
return game.coalitionEscrow!.pledges.find(row=>row.donor===donor && row.recipient==='a')?.amount ?? 0;
|
||||
}
|
||||
function passResponses(game:Game):Game {
|
||||
for(let count=0;game.response && count<20;count++) {
|
||||
const actor=game.players.find(p=>!game.response!.passed.includes(p.id)); assert.ok(actor);
|
||||
game=send(game,actor.id,{type:'passResponse'});
|
||||
}
|
||||
assert.equal(game.response ?? null,null); return game;
|
||||
}
|
||||
|
||||
void test('four-person unlimited group needs every approval and bots choose legal answers',()=>{
|
||||
let game=add(pair(),'c'); game=propose(game,'d');
|
||||
const offer=game.coalitionState!.proposal!;
|
||||
assert.deepEqual(offer.original.members,['a','b','c']);
|
||||
for(const difficulty of DIFFICULTIES) for(const id of ['b','c','d']) {
|
||||
const view=viewGame(game,id); view.players.find(p=>p.id===id)!.bot=difficulty;
|
||||
const action=botActions(view)[0]; assert.ok(action);
|
||||
assert.equal(action.type,'coalitionAnswer'); assert.equal(action.accept,true);
|
||||
const result=send(game,id,action);
|
||||
assert.ok(result.coalitionState!.proposal?.approvals.includes(id));
|
||||
}
|
||||
game=answer(game,'b'); game=answer(game,'c');
|
||||
assert.deepEqual(coalitionOf(game.players,game.coalitionState,'a')!.members,['a','b','c']);
|
||||
game=answer(game,'d');
|
||||
const group=coalitionOf(game.players,game.coalitionState,'a'); assert.ok(group);
|
||||
assert.deepEqual(group.members,['a','b','c','d']); assert.equal(group.edges.length,6);
|
||||
assert.equal(game.coalitionState!.proposal,null);
|
||||
assert.ok(group.members.every(id=>seat(game,id).ally===null));
|
||||
});
|
||||
|
||||
void test('each bot difficulty can request admission to an existing four-person alliance',()=>{
|
||||
const game=four();
|
||||
const group=coalitionOf(game.players,game.coalitionState,'a'); assert.ok(group);
|
||||
for(const difficulty of DIFFICULTIES) {
|
||||
const view=viewGame(game,'e'); view.players.find(player=>player.id==='e')!.bot=difficulty;
|
||||
const action=botActions(view)[0]; assert.ok(action);
|
||||
assert.deepEqual(action,{type:'coalitionPropose',groupId:group.id,entrant:'e',
|
||||
expectedRevision:game.coalitionState!.revision});
|
||||
let pending=send(game,'e',action);
|
||||
assert.equal(pending.coalitionState!.proposal?.proposer,'e');
|
||||
assert.deepEqual(pending.coalitionState!.proposal?.approvals,['e']);
|
||||
for(const id of ['a','b','c','d']) pending=answer(pending,id);
|
||||
assert.deepEqual(coalitionOf(pending.players,pending.coalitionState,'e')!.members,['a','b','c','d','e']);
|
||||
}
|
||||
});
|
||||
|
||||
void test('actual table shows four allies and fifth-player invitation to all participants',()=>{
|
||||
const game=propose(four(),'e');
|
||||
const offer=game.coalitionState!.proposal; assert.ok(offer);
|
||||
assert.deepEqual(offer.original.members,['a','b','c','d']);
|
||||
for(const id of ['a','b','c','d','e']) {
|
||||
const view=viewGame(game,id);
|
||||
const markup=renderToStaticMarkup(createElement(GameTable,{game:view,busy:false,send:async()=>{},onExit(){}}));
|
||||
assert.match(markup,/Allied with (?:<!-- -->)?Emperor and Fremen and Harkonnen/);
|
||||
assert.match(markup,/Waiting for:/);
|
||||
assert.match(markup,/Guild/);
|
||||
if(id!=='a') assert.match(markup,/Accept invitation/);
|
||||
else assert.match(markup,/Withdraw and cancel invitation/);
|
||||
}
|
||||
});
|
||||
|
||||
void test('three named allies fund one physical shipment once',()=>{
|
||||
let game=movement(four()); seat(game,'a').spice=3;
|
||||
for(const donor of ['b','c','d']) game=pledge(game,donor,2);
|
||||
assert.equal(viewGame(game,'a').coalitionFunding?.incoming.length,3);
|
||||
game=send(game,'a',{type:'ship',territory:'imperial_basin',sector:9,amount:3,elite:0,
|
||||
allyPayment:0,coalitionContributions:['b','c','d'].map(donor=>({donor,amount:1}))});
|
||||
game=passResponses(game);
|
||||
assert.equal(seat(game,'a').forces['imperial_basin:9'],3);
|
||||
assert.equal(seat(game,'a').reserves,17); assert.equal(seat(game,'a').spice,0);
|
||||
assert.equal(seat(game,'e').spice,16);
|
||||
for(const donor of ['b','c','d']) {assert.equal(seat(game,donor).spice,8); assert.equal(remaining(game,donor),1);}
|
||||
});
|
||||
|
||||
void test('departure retains group and surviving escrow edges, then restores a pair and allows rejoining',()=>{
|
||||
let game=movement(four());
|
||||
game=pledge(game,'b',2); game=pledge(game,'c',3); game=pledge(game,'d',4);
|
||||
// Preserve the actual escrow while staging the next Nexus; no phase-end
|
||||
// refunds are simulated, so departing-edge refunds are directly observable.
|
||||
Object.assign(game,{phase:1,nexus:true,active:null,ready:[]});
|
||||
const epoch=game.coalitionEscrow!.authorizations.find(row=>row.members.join('')==='ab')!.epoch;
|
||||
game=send(game,'d',{type:'coalitionLeave',expectedRevision:game.coalitionState!.revision});
|
||||
assert.deepEqual(coalitionOf(game.players,game.coalitionState,'a')!.members,['a','b','c']);
|
||||
assert.equal(seat(game,'d').spice,10); assert.equal(remaining(game,'d'),0);
|
||||
assert.equal(remaining(game,'b'),2); assert.equal(remaining(game,'c'),3);
|
||||
game=send(game,'c',{type:'coalitionLeave',expectedRevision:game.coalitionState!.revision});
|
||||
assert.deepEqual(coalitionOf(game.players,game.coalitionState,'a')!.members,['a','b']);
|
||||
assert.equal(seat(game,'a').ally,'b'); assert.equal(seat(game,'b').ally,'a');
|
||||
assert.equal(seat(game,'c').spice,10); assert.equal(remaining(game,'c'),0);
|
||||
assert.equal(remaining(game,'b'),2);
|
||||
assert.equal(game.coalitionEscrow!.authorizations.find(row=>row.members.join('')==='ab')!.epoch,epoch);
|
||||
game=add(game,'d');
|
||||
assert.deepEqual(coalitionOf(game.players,game.coalitionState,'a')!.members,['a','b','d']);
|
||||
assert.equal(remaining(game,'b'),2); assert.equal(remaining(game,'d'),0);
|
||||
});
|
||||
|
||||
void test('unlimited alliance requires three distinct strongholds while the existing staged group retains four',()=>{
|
||||
const game=four(); game.phase=8; game.nexus=false; game.storm=18;
|
||||
for(const player of game.players) player.forces={};
|
||||
seat(game,'a').forces={'arrakeen:10':1}; seat(game,'b').forces={'carthag:11':1};
|
||||
seat(game,'c').forces={'sietch_tabr:14':1};
|
||||
const row=strongholdProgress(game).progress.find(progress=>progress.player==='a'); assert.ok(row);
|
||||
assert.equal(row.target,3); assert.equal(row.qualifies,true);
|
||||
assert.deepEqual(quoteVictory(game).winner,['a','b','c','d']);
|
||||
const old=add(pair('legacy'),'c'); old.phase=8; old.nexus=false; old.storm=18;
|
||||
for(const player of old.players) player.forces={};
|
||||
seat(old,'a').forces={'arrakeen:10':1}; seat(old,'b').forces={'carthag:11':1};
|
||||
seat(old,'c').forces={'sietch_tabr:14':1};
|
||||
const original=strongholdProgress(old).progress.find(progress=>progress.player==='a'); assert.ok(original);
|
||||
assert.equal(original.target,4); assert.equal(original.qualifies,false);
|
||||
});
|
||||
|
||||
void test('unlimited variant also gives a pair the three-stronghold target',()=>{
|
||||
const game=pair(); game.phase=8; game.nexus=false; game.storm=18;
|
||||
for(const player of game.players) player.forces={};
|
||||
seat(game,'a').forces={'arrakeen:10':1}; seat(game,'b').forces={'carthag:11':1,'sietch_tabr:14':1};
|
||||
const row=strongholdProgress(game).progress.find(progress=>progress.player==='a'); assert.ok(row);
|
||||
assert.equal(row.target,3); assert.equal(row.qualifies,true);
|
||||
assert.deepEqual(quoteVictory(game).winner,['a','b']);
|
||||
});
|
||||
|
||||
void test('victory continuation accepts a four-player side and a distinct qualifying side',()=>{
|
||||
// Public continuation is exercised with two synthetic already-qualifying
|
||||
// sides; actual four-member victory is independently quoted above.
|
||||
let frame=createVictoryContinuation({event:'continue-four',turn:2,resume:'mentat',
|
||||
order:['a','b','c','d','e'],sides:[['a','b','c','d'],['e']]});
|
||||
assert.deepEqual(frame.sides,[['a','b','c','d'],['e']]);
|
||||
assert.deepEqual(frame.voters,['a','b','c','d','e']);
|
||||
for(const [index,id] of frame.voters.entries()) {
|
||||
const result=voteVictoryContinuation(frame,{event:'continue-four',player:id,choice:'continue',
|
||||
...(index===0 ? {extraStrongholds:1} : {})});
|
||||
if(result.outcome==='pending') frame=result.frame;
|
||||
else {assert.equal(index,4); assert.deepEqual(result,{outcome:'continue',extraStrongholds:1});}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
beginCoalitionAdmission, answerCoalitionAdmission, coalitionAllies, coalitionGroups,
|
||||
coalitionOf, emptyCoalitionState, leaveCoalition,
|
||||
type CoalitionSeat, type CoalitionState, type CoalitionTransition,
|
||||
} from '../game/coalition-alliance';
|
||||
|
||||
function initial() {
|
||||
const seats: CoalitionSeat[] = [
|
||||
{id:'a', ally:'b', allySinceTurn:1}, {id:'b', ally:'a', allySinceTurn:2},
|
||||
...['c','d','e','f'].map(id => ({id, ally:null})),
|
||||
];
|
||||
return {seats, state:emptyCoalitionState('unlimited')};
|
||||
}
|
||||
function apply(seats: CoalitionSeat[], transition: CoalitionTransition): CoalitionState {
|
||||
for (const seat of seats) {
|
||||
if (Object.hasOwn(transition.allyUpdates, seat.id)) seat.ally = transition.allyUpdates[seat.id];
|
||||
if (Object.hasOwn(transition.allySinceUpdates, seat.id)) {
|
||||
const value = transition.allySinceUpdates[seat.id];
|
||||
if (value === null) delete seat.allySinceTurn;
|
||||
else seat.allySinceTurn = value;
|
||||
}
|
||||
}
|
||||
return transition.state;
|
||||
}
|
||||
function invite(seats: CoalitionSeat[], state: CoalitionState, entrant: string, turn: number): CoalitionState {
|
||||
const group = coalitionOf(seats, state, 'a'); assert.ok(group);
|
||||
const event = `invite-${entrant}-${turn}`;
|
||||
state = apply(seats, beginCoalitionAdmission(seats, state, {event, nexus:`nexus-${turn}`, turn,
|
||||
groupId:group.id, entrant, proposer:'a', expectedRevision:state.revision}));
|
||||
const participants = [...group.members.filter(member => member !== 'a'), entrant];
|
||||
for (const actor of participants) {
|
||||
const previous = coalitionOf(seats, state, 'a')!.members;
|
||||
state = apply(seats, answerCoalitionAdmission(seats, state, {event, nexus:`nexus-${turn}`,
|
||||
turn, actor, accept:true, expectedRevision:state.revision}));
|
||||
if (actor !== entrant) assert.deepEqual(coalitionOf(seats, state, 'a')!.members, previous,
|
||||
'The existing group does not expand before every participant approves.');
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
void test('unlimited membership grows to six with unanimous approval and retained edge ages', () => {
|
||||
const {seats} = initial(); let state = emptyCoalitionState('unlimited');
|
||||
for (const [index, entrant] of ['c','d','e','f'].entries()) state = invite(seats, state, entrant, index + 3);
|
||||
const group = coalitionOf(seats,state,'a'); assert.ok(group);
|
||||
assert.deepEqual(group.members,['a','b','c','d','e','f']);
|
||||
assert.equal(group.edges.length,15);
|
||||
assert.deepEqual(group.edges.find(edge => edge.members.join('') === 'ab')?.legacySince,[1,2]);
|
||||
assert.equal(group.edges.find(edge => edge.members.join('') === 'ad')?.formedTurn,4);
|
||||
assert.deepEqual(coalitionAllies(seats,state,'a'),['b','c','d','e','f']);
|
||||
assert.ok(seats.every(seat => seat.ally === null));
|
||||
assert.equal(coalitionGroups(seats, JSON.parse(JSON.stringify(state))).length,1);
|
||||
const before = structuredClone(state);
|
||||
assert.throws(() => beginCoalitionAdmission(seats,state,{event:'full',nexus:'n',turn:8,
|
||||
groupId:group.id,entrant:'f',proposer:'a',expectedRevision:state.revision}));
|
||||
assert.deepEqual(state,before);
|
||||
});
|
||||
|
||||
void test('departures preserve surviving edges and restore the original pair scalar ages', () => {
|
||||
const {seats} = initial(); let state = emptyCoalitionState('unlimited');
|
||||
state = invite(seats,state,'c',3); state = invite(seats,state,'d',4);
|
||||
state = apply(seats,leaveCoalition(seats,state,{actor:'d',expectedRevision:state.revision}));
|
||||
let group = coalitionOf(seats,state,'a'); assert.ok(group);
|
||||
assert.deepEqual(group.members,['a','b','c']); assert.equal(group.edges.length,3);
|
||||
assert.equal(group.edges.find(edge => edge.members.join('') === 'ac')?.formedTurn,3);
|
||||
state = apply(seats,leaveCoalition(seats,state,{actor:'c',expectedRevision:state.revision}));
|
||||
group = coalitionOf(seats,state,'a'); assert.ok(group);
|
||||
assert.deepEqual(group.members,['a','b']);
|
||||
assert.equal(seats[0].ally,'b'); assert.equal(seats[1].ally,'a');
|
||||
assert.equal(seats[0].allySinceTurn,1); assert.equal(seats[1].allySinceTurn,2);
|
||||
assert.equal(seats[2].ally,null); assert.equal(seats[3].ally,null);
|
||||
state = invite(seats,state,'d',5);
|
||||
assert.deepEqual(coalitionOf(seats,state,'a')?.members,['a','b','d']);
|
||||
assert.deepEqual(coalitionOf(seats,state,'a')?.edges.find(edge => edge.members.join('') === 'ab')?.legacySince,[1,2]);
|
||||
});
|
||||
|
||||
void test('decline, stale answers and an omitted variant preserve the original alliance', () => {
|
||||
const {seats} = initial(); let state = emptyCoalitionState('unlimited');
|
||||
state = invite(seats,state,'c',3);
|
||||
const group = coalitionOf(seats,state,'a'); assert.ok(group);
|
||||
state = apply(seats,beginCoalitionAdmission(seats,state,{event:'invite-d',nexus:'n',turn:4,
|
||||
groupId:group.id,entrant:'d',proposer:'d',expectedRevision:state.revision}));
|
||||
const before = structuredClone(state);
|
||||
assert.throws(() => answerCoalitionAdmission(seats,state,{event:'invite-d',nexus:'n',turn:4,
|
||||
actor:'a',accept:true,expectedRevision:state.revision - 1}));
|
||||
assert.deepEqual(state,before);
|
||||
state = apply(seats,answerCoalitionAdmission(seats,state,{event:'invite-d',nexus:'n',turn:4,
|
||||
actor:'b',accept:false,expectedRevision:state.revision}));
|
||||
assert.equal(state.proposal,null); assert.deepEqual(coalitionOf(seats,state,'a')?.members,['a','b','c']);
|
||||
const defaultState = {...state}; delete defaultState.variant;
|
||||
assert.throws(() => beginCoalitionAdmission(seats,defaultState,{event:'new',nexus:'n',turn:4,
|
||||
groupId:group.id,entrant:'d',proposer:'a',expectedRevision:defaultState.revision}));
|
||||
assert.throws(() => coalitionGroups(seats,{...state,variant:'invalid'} as unknown as CoalitionState));
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { emptyCoalitionState, type CoalitionEdge } from '../game/coalition-alliance';
|
||||
import { coalitionBattleBeneficiary } from '../game/coalition-battle-preparation';
|
||||
import {
|
||||
createCoalitionWormProtection, currentCoalitionWormAlly,
|
||||
decideCoalitionWormProtection, protectedCoalitionWormAllies,
|
||||
resolveCoalitionWormProtection, validateCoalitionWormProtection,
|
||||
} from '../game/coalition-worm-protection';
|
||||
import { projectFremenGroupRevivalGrant, quoteFremenRevivalGrant } from '../game/fremen-revival-grant';
|
||||
import { createGame, newPlayer, type Game } from '../game/engine';
|
||||
|
||||
/** Staged unlimited ledger; lobby entry and actual admission are covered by
|
||||
* integration tests. Every alliance edge has its original public turn. */
|
||||
function source(members: string[]): Game {
|
||||
const game = createGame('GROUPPOWERS', newPlayer('f', 'Fremen', 'fremen'), false);
|
||||
game.players.push(newPlayer('a', 'Atreides', 'atreides'),
|
||||
newPlayer('b', 'Bene Gesserit', 'beneGesserit'), newPlayer('e', 'Emperor', 'emperor'),
|
||||
newPlayer('g', 'Guild', 'guild'), newPlayer('h', 'Harkonnen', 'harkonnen'));
|
||||
Object.assign(game, {status:'playing', phase:1, turn:3, order:['f','e','g','a','h','b']});
|
||||
const sorted = [...members].sort();
|
||||
const edges: CoalitionEdge[] = [];
|
||||
for (let i = 0; i < sorted.length; i++)
|
||||
for (let j = i + 1; j < sorted.length; j++)
|
||||
edges.push({members:[sorted[i], sorted[j]], formedTurn:2});
|
||||
game.coalitionState = {...emptyCoalitionState(), variant:'unlimited' as const,
|
||||
groups:[{id:'group', members:sorted, formedTurn:2, edges}]};
|
||||
for (const seat of game.players) {
|
||||
seat.forces = {}; seat.reserves = 20; seat.tanks = 0;
|
||||
seat.ally = null;
|
||||
}
|
||||
return game;
|
||||
}
|
||||
|
||||
void test('one worm resolves five independently selected allied protections in storm order', () => {
|
||||
const game = source(['f','a','b','e','g','h']);
|
||||
for (const seat of game.players.filter(player => player.id !== 'f')) {
|
||||
seat.forces = {'red_chasm:7':3}; seat.reserves = 17;
|
||||
}
|
||||
let frame = createCoalitionWormProtection(game, 'f', 'red_chasm', 'worm-event');
|
||||
assert.ok(frame);
|
||||
assert.deepEqual(frame.order, ['e','g','a','h','b']);
|
||||
for (const [ally, outcome] of [['e','allowed'], ['g','canceled'], ['a','declined'],
|
||||
['h','allowed'], ['b','allowed']] as const) {
|
||||
assert.equal(currentCoalitionWormAlly(game, frame), ally);
|
||||
frame = decideCoalitionWormProtection(game, frame, 'f', 'worm-event', ally, outcome !== 'declined');
|
||||
if (outcome !== 'declined') frame = resolveCoalitionWormProtection(game, frame, 'f', 'worm-event', ally, outcome === 'canceled');
|
||||
}
|
||||
assert.equal(frame.stage, 'complete');
|
||||
assert.deepEqual(protectedCoalitionWormAllies(game, frame), ['e','h','b']);
|
||||
assert.deepEqual(frame.outcomes.map(row => row.outcome), ['allowed','canceled','declined','allowed','allowed']);
|
||||
const stale = structuredClone(frame);
|
||||
stale.order = [...stale.order].reverse();
|
||||
assert.throws(() => validateCoalitionWormProtection(game, stale));
|
||||
});
|
||||
|
||||
void test('Fremen can independently select each of five allied revival recipients', () => {
|
||||
const game = source(['f','a','b','e','g','h']);
|
||||
game.phase = 4;
|
||||
const grants = projectFremenGroupRevivalGrant(game, 'f');
|
||||
assert.ok(grants);
|
||||
assert.deepEqual(grants.map(row => row.recipient).sort(), ['a','b','e','g','h']);
|
||||
for (const ally of ['a','b','e','g','h']) {
|
||||
assert.deepEqual(quoteFremenRevivalGrant(game, 'f', ally), {turn:3,owner:'f',recipient:ally});
|
||||
game.freeRevival.push(ally);
|
||||
assert.throws(() => quoteFremenRevivalGrant(game, 'f', ally));
|
||||
}
|
||||
assert.throws(() => quoteFremenRevivalGrant(game, 'f', 'f'));
|
||||
game.advanced = true;
|
||||
assert.equal(projectFremenGroupRevivalGrant(game, 'f'), null);
|
||||
});
|
||||
|
||||
void test('group battle preparation selects any single member and rejects two allied sides', () => {
|
||||
const game = source(['f','a','b','e']);
|
||||
game.phase = 6;
|
||||
for (const ally of ['f','b','e'])
|
||||
assert.equal(coalitionBattleBeneficiary(game, 'a', ally, 'g'), ally);
|
||||
assert.equal(coalitionBattleBeneficiary(game, 'a', 'e', 'b'), null);
|
||||
assert.equal(coalitionBattleBeneficiary(game, 'a', 'g', 'h'), null);
|
||||
const unsupported = {...game, advanced:true};
|
||||
assert.equal(coalitionBattleBeneficiary(unsupported, 'a', 'e', 'g'), null);
|
||||
});
|
||||
Reference in new issue
Block a user