Connect Kull bid answers and group transport
Verify and publish checkpoint container / container (push) Successful in 12m6s
Verify and publish checkpoint container / container (push) Successful in 12m6s
This commit is contained in:
1 parent
420943b890
commit
f667383565
16 files changed
+438
-26
No files matched your search
@@ -285,6 +285,7 @@ import { MoritaniAssassinate, MoritaniAssassinateHistory } from './moritani-assa
|
||||
import { MoritaniExtortion, MoritaniExtortionNotice } from './moritani-extortion';
|
||||
import { AdvancedAllySeparation } from './advanced-ally-separation';
|
||||
import { CoalitionNexus } from './coalition-nexus';
|
||||
import { RosterAlliance } from './roster-alliance';
|
||||
import { CoalitionFunding, coalitionFundingContributions, type CoalitionFundingContribution } from './coalition-funding';
|
||||
import { CoalitionEmperorAid } from './coalition-emperor-aid';
|
||||
import { NexusEmperorPayment } from './nexus-emperor-payment';
|
||||
@@ -423,7 +424,7 @@ export function GameTable({
|
||||
const guildTransport =
|
||||
useGuildSecret ||
|
||||
me.faction === 'guild' ||
|
||||
!!g.coalitionFunding && groupGuildShipmentRate(g.players, me.id, g.coalitionState) ||
|
||||
groupGuildShipmentRate(g.players, me.id, g.coalitionState) ||
|
||||
g.players.some((p) => p.faction === 'guild' && p.id === me.ally);
|
||||
const [botFaction, setBotFaction] = useState('');
|
||||
const [botDifficulty, setBotDifficulty] = useState<Difficulty>('Medium');
|
||||
@@ -1626,15 +1627,7 @@ export function GameTable({
|
||||
` · ${g.techTokens![id].spice} spice at phase end`}
|
||||
</div>
|
||||
))}
|
||||
{p.ally && (
|
||||
<div className="fine">
|
||||
Allied with{' '}
|
||||
{
|
||||
faction(g.players.find((x) => x.id === p.ally)!.faction)
|
||||
.name
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
<RosterAlliance game={g} playerId={p.id} />
|
||||
</div>
|
||||
))}
|
||||
{g.status === 'lobby' &&
|
||||
@@ -2260,7 +2253,7 @@ export function GameTable({
|
||||
Structured bid, alliance, Revival, bribe and combined turn-action questions are unavailable.
|
||||
</> : <>
|
||||
Requires Basic CHOAM with the CHOAM and Ix decks plus each faction’s deck, and no other optional modules.
|
||||
Phase-wide bid and combined turn-action Truthtrance questions are unavailable.
|
||||
Combined turn-action Truthtrance questions are unavailable.
|
||||
When Richese is seated, structured bid, alliance, Revival and bribe commitments are also unavailable,
|
||||
and some Kull reactions may be unavailable in complex response windows.
|
||||
</>}{' '}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import { faction } from '@/game/catalog';
|
||||
import { coalitionAllies } from '@/game/coalition-alliance';
|
||||
import type { GameView } from '@/game/engine';
|
||||
|
||||
/** Alliance membership is public; a staged larger alliance has no scalar
|
||||
* player.ally value, so use its authoritative public membership ledger. */
|
||||
export function RosterAlliance({game, playerId}: {
|
||||
game: Pick<GameView, 'players' | 'coalitionState'>;
|
||||
playerId: string;
|
||||
}) {
|
||||
const player = game.players.find(seat => seat.id === playerId);
|
||||
if (!player) return null;
|
||||
const ids = game.coalitionState
|
||||
? coalitionAllies(game.players, game.coalitionState, playerId)
|
||||
: player.ally ? [player.ally] : [];
|
||||
const names = ids.flatMap(id => {
|
||||
const ally = game.players.find(seat => seat.id === id);
|
||||
return ally ? [faction(ally.faction).name] : [];
|
||||
});
|
||||
return names.length ? <div className="fine">Allied with {names.join(' and ')}</div> : null;
|
||||
}
|
||||
@@ -1,5 +1,18 @@
|
||||
# Kull Wahad: source limits and Karama interruption design
|
||||
|
||||
## Basic phase-wide bid answers — 29 October 2026
|
||||
|
||||
**Prototyped / Partial.** In the existing supported Basic printed-Kull profile
|
||||
without Richese, phase-wide ordinary bid Truthtrance answers can coexist with
|
||||
a Kull interception. The pending response holds the original answer; after
|
||||
Kull succeeds or a distinct Karama counters it, the ordinary bidding guide
|
||||
continues and a real later lot can satisfy a Yes. A No still caps bids. Future
|
||||
funding witnesses respect the existing non-CHOAM unfunded-overbid fence even
|
||||
when that player holds Karama. Richese/Semuta with Kull, combined turn-action
|
||||
questions, Advanced Kull and broader combinations remain separate. This uses
|
||||
the existing provisional Truthtrance and Kull timings, not a new publisher
|
||||
ruling. [Focused evidence](../tests/kull-phase-bid-runtime.test.ts).
|
||||
|
||||
## Basic Guild and Revival responses — 28 October 2026
|
||||
|
||||
**Prototyped / Partial.** In the supported Basic CHOAM/Richese preview, a
|
||||
@@ -63,7 +76,7 @@ answer; a distinct counter resumes the original payment.
|
||||
This uses the existing provisional Kull timing, not a new publisher ruling.
|
||||
The earlier description below that excludes all three question families is
|
||||
superseded for this non-Richese Basic profile. The Richese Semuta envelope,
|
||||
phase-wide bid questions, combined turn-action questions, Nexus Kull, Advanced,
|
||||
combined turn-action questions, Nexus Kull, Advanced,
|
||||
unfunded non-CHOAM overbids and wider combinations remain outside this slice.
|
||||
Automatic Kull activation and full rules/deployed acceptance remain open.
|
||||
The real-start evidence is in [bid](../tests/kull-bid-runtime.test.ts),
|
||||
|
||||
@@ -17,6 +17,25 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**Basic Kull and phase-wide bid Truthtrance — 29 October. Prototyped / Partial.**
|
||||
In the supported non-Richese Basic Kull profile, a phase-wide ordinary bid
|
||||
answer survives Kull interruption and its distinct counter; existing funding
|
||||
and deferred overbid restrictions remain. Richese/Semuta with Kull, combined
|
||||
turn-action questions and complete modes remain unfinished.
|
||||
The 12-file related rule/control union passed 61 cases; the new Kull cases use
|
||||
a real Basic start and genuine card/auction actions, with a staged wallet only
|
||||
for the insufficient-funds check. Typecheck, lint and the production build
|
||||
pass; the updated lobby warning has separate rendered-control coverage.
|
||||
[Scope](CHOAM_KULL_DESIGN.md#basic-phase-wide-bid-answers--29-october-2026).
|
||||
|
||||
**Staged group roster and self-funded Guild transport — 29 October. Prototyped / Partial.**
|
||||
The roster shows both group allies, and an unrelated unresolved commitment no
|
||||
longer disables an ally's self-funded Guild transport. Group funding keeps its
|
||||
own restrictions; group games remain unavailable from the lobby.
|
||||
Six focused cases include the actual table rendering, staged commitments and
|
||||
real group formation/transport actions.
|
||||
[Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-roster-and-self-funded-guild-transport--29-october-2026).
|
||||
|
||||
**Staged group actions after Truthtrance history — 29 October. Prototyped / Partial.**
|
||||
Settled commitment records no longer permanently disable group Nexus and
|
||||
funding. Unresolved records remain blocked until reconciliation, and active
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# Optional three-stronghold alliance victory
|
||||
|
||||
## Group roster and self-funded Guild transport — 29 October 2026
|
||||
|
||||
**Staged Basic prototype, not a selectable game mode.** The public roster
|
||||
names both members of a player's group outside the Nexus. A Guild group ally
|
||||
can use its self-funded native transport even when an unrelated unresolved
|
||||
Truthtrance commitment hides group-funding controls. The original permission
|
||||
and native rate cancellation windows remain in force; group-funded transport
|
||||
continues to use its separate commitment restrictions. Applying the pair
|
||||
privilege to both group allies remains provisional. Full group play and lobby
|
||||
entry remain unfinished.
|
||||
|
||||
## Group actions after Truthtrance history — 29 October 2026
|
||||
|
||||
**Staged Basic prototype, not a selectable game mode.** Fulfilled and released
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# Rule decision index
|
||||
|
||||
## Basic Kull and phase-wide bid answers — 29 October 2026
|
||||
|
||||
The supported non-Richese Basic Kull profile now retains a phase-wide bid
|
||||
answer through its original interruption. Existing provisional Kull and
|
||||
Truthtrance timing and the non-CHOAM overbid restriction still apply.
|
||||
[Scope](CHOAM_KULL_DESIGN.md#basic-phase-wide-bid-answers--29-october-2026).
|
||||
|
||||
## Group roster and self-funded Guild transport — 29 October 2026
|
||||
|
||||
The staged group prototype shows all allied factions in the roster and
|
||||
does not tie a self-funded Guild ally's native transport to the optional group
|
||||
funding gate. The pair privilege's extension to two allies is provisional.
|
||||
[Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-roster-and-self-funded-guild-transport--29-october-2026).
|
||||
|
||||
## Group actions after Truthtrance history — 29 October 2026
|
||||
|
||||
Settled commitment records no longer prevent staged group negotiation and
|
||||
|
||||
+1
-1
@@ -3743,7 +3743,7 @@ function policyActions(g: GameView): Action[] {
|
||||
const guildTransport =
|
||||
guildSecret ||
|
||||
me.faction === 'guild' ||
|
||||
!!g.coalitionFunding && groupGuildRate ||
|
||||
groupGuildRate ||
|
||||
g.players.some((p) => p.faction === 'guild' && p.id === me.ally);
|
||||
if (shipmentAvailable && guildTransport)
|
||||
for (const to of targets
|
||||
|
||||
+4
-4
@@ -16958,7 +16958,8 @@ function bidQuestionWindow(g: Game): boolean {
|
||||
function phaseBidWitnesses(g: Game, p: Player): PhaseBidOpportunity[] {
|
||||
if (g.status !== 'playing' || !phaseBidPromiseModeSupported(g) || g.phase > 3) return [];
|
||||
const maximum = (spent = 0, spentAid = 0, canUseKarama = true) => {
|
||||
if (canUseKarama && karamaCard(g, p, undefined, 'auction-free-purchase')) return Number.MAX_SAFE_INTEGER;
|
||||
if (canUseKarama && (!g.kullPreview || p.faction === 'choam') &&
|
||||
karamaCard(g, p, undefined, 'auction-free-purchase')) return Number.MAX_SAFE_INTEGER;
|
||||
return Math.max(0, uncommittedSpice(g, p) - spent + Math.max(0, (aidFor(g, p)?.amount ?? 0) - spentAid));
|
||||
};
|
||||
if (g.phase < 3) {
|
||||
@@ -38276,7 +38277,7 @@ function ordinaryGuildTransportFullCost(g: Game, p: Player, frame: GuildTranspor
|
||||
const guild = byFaction(g, 'guild');
|
||||
if (!guild ||
|
||||
(p.id !== guild.id && !(p.ally === guild.id && guild.ally === p.id) &&
|
||||
!(coalitionFundingAvailable(g, p) && guildGroupAlly(g, p))) ||
|
||||
!guildGroupAlly(g, p)) ||
|
||||
g.karamaShipping?.player === p.id ||
|
||||
(g.guildRateBlocked?.turn === g.turn && g.guildRateBlocked.player === p.id))
|
||||
return null;
|
||||
@@ -38437,8 +38438,7 @@ function nativeGuildTransportPermission(g: Game, p: Player): boolean {
|
||||
if (guildTransportCanceled(g, p.id)) return false;
|
||||
if (p.faction === 'guild') return true;
|
||||
const guild = byFaction(g, 'guild');
|
||||
return !!guild && (p.ally === guild.id && guild.ally === p.id ||
|
||||
coalitionFundingAvailable(g, p) && guildGroupAlly(g, p));
|
||||
return !!guild && (p.ally === guild.id && guild.ally === p.id || guildGroupAlly(g, p));
|
||||
}
|
||||
function validateGuildTransportForCommit(
|
||||
g: Game, p: Player, frame: GuildTransportOrder, fromReserves: boolean, guildSecretEvent?:string,
|
||||
|
||||
@@ -14,7 +14,7 @@ export const FACTION_RULES: Record<
|
||||
'Use Kulon on your movement turn to add one territory of range. Use La La La during Revival to prevent a player’s free force returns for the phase; a reactive decision lets you stop a pending free revival before it resolves. Both cards have Karama responses before discard.',
|
||||
'Trip to Gamont returns one other player’s force from a chosen sector to its reserves during Mentat. Elite types are preserved. A Karama response precedes the return, and CHOAM gets a final opportunity after the closing market before victory is checked.',
|
||||
'Baliset prevents a player from moving into a territory occupied by CHOAM for the turn, while permitting shipment. Jubba Cloak protects CHOAM forces in one territory from a moving storm. Both powers have Karama responses before the card is discarded.',
|
||||
'New supported Basic lobbies with CHOAM and the CHOAM and Ix decks enable the Kull preview by default; the host may turn it off. Each selected faction needs its deck, and other optional modules remain outside this profile. React to a legal Karama attempt with held Kull Wahad before costs. The original card stays reserved; only a different eligible Karama can counter before the phase ban applies. Success spends Kull, retains the original unplayed card and prevents the activating player’s Karama uses for the phase. Decline or prevention resumes the original once; a prevented Kull stays held but its physical card’s effect is blocked that phase. With Richese, supported Kull and counter discards offer Semuta, while structured bid, Revival, alliance and bribe commitments are unavailable. Phase-wide bid and combined turn-action questions and unfunded non-CHOAM Karama-dependent overbids remain deferred; see the Kull guide. Complete CHOAM rules remain unfinished.',
|
||||
'New supported Basic lobbies with CHOAM and the CHOAM and Ix decks enable the Kull preview by default; the host may turn it off. Each selected faction needs its deck, and other optional modules remain outside this profile. React to a legal Karama attempt with held Kull Wahad before costs. The original card stays reserved; only a different eligible Karama can counter before the phase ban applies. Success spends Kull, retains the original unplayed card and prevents the activating player’s Karama uses for the phase. Decline or prevention resumes the original once; a prevented Kull stays held but its physical card’s effect is blocked that phase. With Richese, supported Kull and counter discards offer Semuta, while structured bid, Revival, alliance and bribe commitments are unavailable. Without Richese, phase-wide bid questions are supported. Combined turn-action questions and unfunded non-CHOAM Karama-dependent overbids remain deferred; see the Kull guide. Complete CHOAM rules remain unfinished.',
|
||||
],
|
||||
advanced: [
|
||||
'Once per game, spend a real Karama and discard selected other cards for three spice each. The cash-in can occur during pending phase or power decisions, while committed battle cards remain protected. See the CHOAM special Karama topic for funding and timing details.',
|
||||
|
||||
+2
-2
@@ -4861,7 +4861,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
summary: 'React before a Karama is played; a distinct counter can stop Kull before its phase restriction begins.',
|
||||
steps: [
|
||||
'In a new supported Basic lobby with CHOAM and the CHOAM and Ix decks, printed Kull is enabled by default as a development preview. The host may turn it off to keep the broader non-Kull Truthtrance options. Basic supports Richese, Ixians, Tleilaxu, Ecaz, Moritani and classic factions with each seated faction’s deck and no other optional modules. In an Advanced preview lobby, the host may explicitly select printed Kull with CHOAM, classic factions and exactly the CHOAM and Ix decks, without optional modules. Advanced supports Bene Gesserit substitutions and legal special powers. Both previews withhold some structured questions and defer unfunded non-CHOAM Karama-dependent bids. Existing games are not retrofitted, and this warning does not certify complete rules.',
|
||||
'In the supported Basic printed Kull preview without Richese, existing current-lot ordinary bid, Revival, alliance and bribe Truthtrance questions are available, including supported grouped and mixed forms. A pending Kull response holds their controls while the original transaction resolves. The existing Richese Semuta preview does not yet admit these structured commitments; phase-wide bid and combined turn-action questions and wider interactions remain incomplete.',
|
||||
'In the supported Basic printed Kull preview without Richese, current-lot and phase-wide ordinary bid, Revival, alliance and bribe Truthtrance questions are available, including supported grouped and mixed forms. A pending Kull response holds their controls while the original transaction resolves. The existing Richese Semuta preview does not yet admit these structured commitments; combined turn-action questions and wider interactions remain incomplete.',
|
||||
'Another player’s legal Karama cancellation, shipment-rate use, immediate auction purchase, winning-bid payment or supported special activation pauses before its card and other costs. CHOAM sees the attempted player and purpose and may decline or choose a legal held Kull Wahad. Each Use names its printed source explicitly. The separate Nexus Kull guide covers additional Cunning costs in its new profile only. The same public opportunity appears without Kull in hand, so waiting does not disclose hidden ownership. Invalid attempts never open this window; CHOAM does not receive a self-targeted Kull offer.',
|
||||
'Decline resumes the original attempt once. Declaring Kull opens the ordinary CHOAM-effect response. Kull is still pending: its target may counter with a different eligible physical Karama before any phase ban applies. The interrupted card stays reserved and cannot serve as its own counter. Other eligible responders use their existing response controls.',
|
||||
'If everyone allows Kull, CHOAM discards the exact Kull card. The original attempted card remains in its owner’s hand; its effect and additional costs do not occur, and an attempted special once-per-game use remains unspent. Basic prevents that player’s Karama activations for this phase. A new Advanced attempt instead prevents only the specific effect: shipment pricing, free Treachery purchase, that faction’s special, or cancellation of the particular advantage. Other effects remain available; printed and substituted Karama share the same restriction. An allied shipment recipient is not automatically the restricted player.',
|
||||
@@ -4876,7 +4876,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
example: 'Bene Gesserit attempts to use Baliset as Karama. CHOAM declares Kull. Baliset cannot also counter Kull; a different eligible Karama may. If Kull succeeds, Baliset stays held; in a new Advanced attempt BG cannot repeat that specific Karama effect this phase but may use another legal effect. If a distinct counter prevents Kull, the original Baliset attempt proceeds to its normal conversion response.',
|
||||
related: ['choam-worthless', 'nexus-choam-kull', 'faction-choam', 'card-karama', 'advanced-beneGesserit', 'special-karama', 'bidding', 'choam-karama', 'nexus-cards', 'implementation-checklist'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Selectable Kull binds typed intents, distinct physical counters and Basic/legacy all-purpose or new Advanced semantic-effect restrictions. Different effects remain playable; phase expiry is provisional in Advanced. Basic printed Kull admits native Richese, Ixians, Tleilaxu, Ecaz and Moritani; supported clean Kull discards offer Semuta before their suspended parent resumes. Without Richese, supported current-lot bid, Revival, alliance and bribe commitments can coexist with the preview; deferred overbid recovery, phase-wide and whole-turn answers and broader module support remain excluded.', evidence: ['game/choam-kull.ts', 'game/kull-purpose.ts', 'game/bribe-promises.ts', 'game/engine.ts', 'tests/kull-bid-runtime.test.ts', 'tests/kull-truth-revival-runtime.test.ts', 'tests/kull-alliance-runtime.test.ts', 'tests/kull-richese-semuta-runtime.test.ts'] },
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Selectable Kull binds typed intents, distinct physical counters and Basic/legacy all-purpose or new Advanced semantic-effect restrictions. Different effects remain playable; phase expiry is provisional in Advanced. Basic printed Kull admits native Richese, Ixians, Tleilaxu, Ecaz and Moritani; supported clean Kull discards offer Semuta before their suspended parent resumes. Without Richese, supported current-lot and phase-wide bid, Revival, alliance and bribe commitments can coexist with the preview; deferred overbid recovery, whole-turn answers and broader module support remain excluded.', evidence: ['game/choam-kull.ts', 'game/kull-purpose.ts', 'game/bribe-promises.ts', 'game/engine.ts', 'tests/kull-bid-runtime.test.ts', 'tests/kull-phase-bid-runtime.test.ts', 'tests/kull-truth-revival-runtime.test.ts', 'tests/kull-alliance-runtime.test.ts', 'tests/kull-richese-semuta-runtime.test.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Owner-only legal held costs and decline use the Kull event; counters reuse ordinary response controls. Public viewers receive purpose/actors, not private intent payloads or hidden choices.', evidence: ['components/choam-kull.tsx', 'components/game-table.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Legal preview participation uses projected reactor choices and ordinary distinct counter controls. Full strategy and difficulty calibration remain deferred.', evidence: ['game/bot-choam-kull.ts', 'game/bots.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'Pending versus active restrictions, distinct custody, decline/prevention/success, BG-before-conversion and deferred overbid limits have internal explanations. Publisher-source and full-faction acceptance remain separate.', evidence: ['docs/CHOAM_KULL_DESIGN.md', 'docs/CHOAM_KULL_SOURCE_UPDATE.md', 'docs/NEXUS_CHOAM_RULES.md'] },
|
||||
|
||||
@@ -42,14 +42,13 @@ export function parsePhaseBidClaim(value: unknown): PhaseBidClaim {
|
||||
return { turn: input.turn, minimum: input.minimum };
|
||||
}
|
||||
|
||||
/** A phase-wide Yes can depend on an unopened later lot; its Kull purchase
|
||||
* interruption needs a separate continuation proof from a current-lot Yes. */
|
||||
export function phaseBidPromiseModeSupported(game: Parameters<typeof bidPromiseModeSupported>[0]): boolean {
|
||||
// Basic native Richese opens Semuta while preparing its cache auction.
|
||||
// This phase-wide contract has its own continuation through that window;
|
||||
// other current-lot bid contracts retain their separate Semuta guard.
|
||||
const basicRichese = !game.advanced && game.players.some(player => player.faction === 'richese');
|
||||
return !game.kullPreview && bidPromiseModeSupported(basicRichese ? { ...game, semutaPreview: undefined } : game);
|
||||
if (basicRichese && game.kullPreview) return false;
|
||||
return bidPromiseModeSupported(basicRichese ? { ...game, semutaPreview: undefined } : game);
|
||||
}
|
||||
|
||||
export function phaseBidClaimText(claim: PhaseBidClaim): string {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
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, 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 group is formed through actual Nexus actions. The turn, forces and
|
||||
* unrelated accepted Truthtrance No answer are staged for this interaction. */
|
||||
function fixture() {
|
||||
let game = createGame('GROUPTRUTHSHIP', newPlayer('g', 'Guild', 'guild'), false);
|
||||
game.players.push(newPlayer('e', 'Emperor', 'emperor'), newPlayer('f', 'Fremen', 'fremen'),
|
||||
newPlayer('h', 'Harkonnen', 'harkonnen'));
|
||||
Object.assign(game, {status:'playing', phase:1, turn:2, nexus:true, order:['g','e','f','h'],
|
||||
ready:[], storm:18, deck:baseDeck(), response:null, decision:null, phaseOpening:null});
|
||||
game.coalitionState = emptyCoalitionState();
|
||||
game = send(game, 'g', {type:'alliance', target:'e'});
|
||||
game = send(game, 'e', {type:'alliance', target:'g'});
|
||||
const pair = coalitionOf(game.players, game.coalitionState, 'g'); assert.ok(pair);
|
||||
game = send(game, 'g', {type:'coalitionPropose', groupId:pair.id, entrant:'f',
|
||||
expectedRevision:game.coalitionState!.revision});
|
||||
for (const id of ['e', 'f']) {
|
||||
const offer = game.coalitionState!.proposal!;
|
||||
game = send(game, id, {type:'coalitionAnswer', event:offer.event, nexus:offer.nexus,
|
||||
turn:offer.turn, accept:true, expectedRevision:game.coalitionState!.revision});
|
||||
}
|
||||
Object.assign(game, {phase:5, nexus:false, active:'e', ready:[],
|
||||
movementRemaining:['e','g','f','h'], guildTimingGranted:false, guildTimingLocked:false});
|
||||
for (const player of game.players) {
|
||||
player.forces = {}; player.reserves = 20; player.tanks = 0; player.spice = 10;
|
||||
player.hand = []; player.traitors = []; player.traitorChoices = [];
|
||||
player.shipped = false; player.moved = 0;
|
||||
}
|
||||
seat(game, 'e').forces = {'imperial_basin:10':3}; seat(game, 'e').reserves = 17;
|
||||
const index = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0);
|
||||
const [karama] = game.deck.splice(index, 1); seat(game, 'h').hand.push(karama);
|
||||
game.bribePromises = [{event:'other-player-no', player:'h', asker:'e', turn:game.turn,
|
||||
recipient:'f', minimum:1, answer:false, progress:0}];
|
||||
return {game, karama};
|
||||
}
|
||||
function passKind(game: Game, kind: string): Game {
|
||||
for (let count = 0; game.response?.kind === kind && count < 24; count++) {
|
||||
const next = game.players.find(player => !game.response!.passed.includes(player.id));
|
||||
assert.ok(next); game = send(game, next.id, {type:'passResponse'});
|
||||
}
|
||||
assert.notEqual(game.response?.kind, kind);
|
||||
return game;
|
||||
}
|
||||
function passAll(game: Game): Game {
|
||||
for (let count = 0; game.response && count < 32; 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;
|
||||
}
|
||||
const transport: Action = {type:'guildShip', forces:{'imperial_basin:10':3},
|
||||
territory:'red_chasm', sector:7, allyPayment:0};
|
||||
|
||||
void test('an unrelated Truthtrance No retains self-funded group Guild transport and both response windows', () => {
|
||||
let {game} = fixture();
|
||||
game = send(game, 'e', transport);
|
||||
assert.equal(game.response?.kind, 'guildTransportPermission');
|
||||
assert.equal(seat(game, 'e').forces['imperial_basin:10'], 3);
|
||||
game = passKind(game, 'guildTransportPermission');
|
||||
assert.equal(game.response?.kind, 'guildRate');
|
||||
assert.equal(game.pendingGuildTransport?.cost, 3);
|
||||
game = passAll(game);
|
||||
assert.equal(seat(game, 'e').forces['red_chasm:7'], 3);
|
||||
assert.equal(seat(game, 'e').spice, 7);
|
||||
assert.equal(seat(game, 'g').spice, 13);
|
||||
assert.ok(game.bribePromises?.some(row => row.event === 'other-player-no' && !row.fulfilled && !row.released));
|
||||
});
|
||||
|
||||
void test('Karama still cancels the native group Guild rate when another player has an unrelated commitment', () => {
|
||||
const fixtureState = fixture();
|
||||
let game = fixtureState.game;
|
||||
const {karama} = fixtureState;
|
||||
game = send(game, 'e', transport);
|
||||
game = passKind(game, 'guildTransportPermission');
|
||||
assert.equal(game.response?.kind, 'guildRate');
|
||||
game = send(game, 'h', {type:'card', mode:'cancel', card:karama.id});
|
||||
game = passAll(game);
|
||||
assert.equal(seat(game, 'e').forces['red_chasm:7'], 3);
|
||||
assert.equal(seat(game, 'e').spice, 4, 'The shipper pays the full six-spice rate.');
|
||||
assert.equal(seat(game, 'g').spice, 16);
|
||||
assert.equal(game.discard.filter(card => card.id === karama.id).length, 1);
|
||||
assert.ok(game.bribePromises?.some(row => row.event === 'other-player-no' && !row.fulfilled && !row.released));
|
||||
});
|
||||
@@ -16,7 +16,7 @@ void test('Basic printed Kull admits native bid commitments only in its supporte
|
||||
const scope = { advanced: false, expansions: ['choam', 'ix'],
|
||||
players: [{ faction: 'choam' }, { faction: 'emperor' }], kullPreview: true };
|
||||
assert.equal(bidPromiseModeSupported(scope), true);
|
||||
assert.equal(phaseBidPromiseModeSupported(scope), false);
|
||||
assert.equal(phaseBidPromiseModeSupported(scope), true);
|
||||
assert.equal(bidPromiseModeSupported({ ...scope, advanced: true }), false);
|
||||
assert.equal(bidPromiseModeSupported({ ...scope, expansions: ['choam'] }), false);
|
||||
assert.equal(bidPromiseModeSupported({ ...scope, semutaPreview: true }), false);
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame, type Game } from '../game/engine';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { botActions } from '../game/bots';
|
||||
import { phaseBidPromiseModeSupported } from '../game/turn-bid-promises';
|
||||
import { act, advance, clean, createKullBribeFixture, inventory, player } from './fixture-kull-bribe';
|
||||
|
||||
/** The fixture selects physical cards in the unopened deal; the Truthtrance,
|
||||
* Kull and auction actions below all use the genuine public Basic start. */
|
||||
function bound(answer: 'yes' | 'no') {
|
||||
const f = createKullBribeFixture();
|
||||
let game = advance(structuredClone(f.charity), state =>
|
||||
state.phase === 3 && clean(state) && state.auction?.active === f.actor);
|
||||
game = act(game, f.asker, {type:'card', card:f.truth});
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const actor = game.players.find(seat => !game.truthtrance!.passed.includes(seat.id));
|
||||
assert.ok(actor);
|
||||
game = act(game, actor.id, {type:'truthPass'});
|
||||
}
|
||||
game = act(game, f.asker, {type:'truthAsk', question:{
|
||||
kind:'phaseBid', target:f.actor, turn:game.turn, minimum:4,
|
||||
}});
|
||||
assert.ok(viewGame(game, f.actor).truthPhaseBidAnswers?.includes(answer));
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game, f.actor);
|
||||
view.players.find(seat => seat.id === f.actor)!.bot = difficulty;
|
||||
const choice = botActions(view)[0];
|
||||
assert.equal(choice?.type, 'truthAnswer');
|
||||
assert.ok(view.truthPhaseBidAnswers!.includes(choice.answer as 'yes' | 'no' | 'unknown'));
|
||||
}
|
||||
game = act(game, f.actor, {type:'truthAnswer', answer});
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
return {f, game};
|
||||
}
|
||||
|
||||
function offered(source: Game, f: ReturnType<typeof createKullBribeFixture>) {
|
||||
const before = structuredClone(source.phaseBidPromises);
|
||||
const lot = source.auction!.cards[source.auction!.index].id;
|
||||
const game = act(source, f.counterOwner, {type:'card', mode:'purchase', card:f.counter});
|
||||
assert.equal(game.pendingKull?.stage, 'offer');
|
||||
assert.deepEqual(game.phaseBidPromises, before);
|
||||
assert.equal(viewGame(game, f.actor).phaseBidPromiseGuide, null);
|
||||
const reaction = viewGame(game, f.reactor).kullReaction; assert.ok(reaction);
|
||||
return {game, reaction, before, lot};
|
||||
}
|
||||
|
||||
void test('pre-bidding phase Yes cannot rely on an unfunded Karama bid in Kull preview', () => {
|
||||
const f = createKullBribeFixture();
|
||||
let game = structuredClone(f.charity);
|
||||
// Controlled wallet supplement: the original physical Karama remains in
|
||||
// Emperor's hand, but its only spice cannot fund the promised four bid.
|
||||
player(game, f.actor).spice = 1;
|
||||
assert.ok(player(game, f.actor).hand.some(card => card.id === f.karama));
|
||||
game = act(game, f.asker, {type:'card', card:f.truth});
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const actor = game.players.find(seat => !game.truthtrance!.passed.includes(seat.id)); assert.ok(actor);
|
||||
game = act(game, actor.id, {type:'truthPass'});
|
||||
}
|
||||
game = act(game, f.asker, {type:'truthAsk', question:{
|
||||
kind:'phaseBid', target:f.actor, turn:game.turn, minimum:4,
|
||||
}});
|
||||
assert.ok(!viewGame(game, f.actor).truthPhaseBidAnswers?.includes('yes'),
|
||||
'A non-CHOAM Karama cannot witness the Kull-fenced unfunded overbid.');
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => act(game, f.actor, {type:'truthAnswer', answer:'yes'}));
|
||||
assert.deepEqual(game, before);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('phase-wide Kull questions remain fenced with native Richese and Semuta', () => {
|
||||
const scope = {advanced:false, expansions:['choam','ix'], kullPreview:true,
|
||||
semutaPreview:true, players:[{faction:'choam'}, {faction:'richese'}]};
|
||||
assert.equal(phaseBidPromiseModeSupported(scope), false);
|
||||
});
|
||||
|
||||
void test('phase-wide Yes survives a real Kull interception and completes its original bid', () => {
|
||||
const {f, game: source} = bound('yes');
|
||||
const pending = offered(source, f);
|
||||
let game = act(pending.game, f.reactor, {type:'kullDecision', event:pending.reaction.event,
|
||||
source:'printed', card:f.kull});
|
||||
assert.equal(game.pendingKull?.stage, 'counter');
|
||||
assert.deepEqual(game.phaseBidPromises, pending.before);
|
||||
game = advance(game, clean);
|
||||
assert.equal(game.auction!.cards[game.auction!.index].id, pending.lot);
|
||||
assert.deepEqual(game.phaseBidPromises, pending.before);
|
||||
const guide = viewGame(game, f.actor).phaseBidPromiseGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type, 'bid'); assert.equal(guide.action.amount, 4);
|
||||
game = act(game, f.actor, guide.action);
|
||||
assert.ok(game.phaseBidPromises?.some(row => row.player === f.actor && row.fulfilled));
|
||||
assert.ok(player(game, f.counterOwner).hand.some(card => card.id === f.counter));
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('phase-wide No still caps a bid after the Kull interception', () => {
|
||||
const {f, game: source} = bound('no');
|
||||
const pending = offered(source, f);
|
||||
let game = act(pending.game, f.reactor, {type:'kullDecision', event:pending.reaction.event,
|
||||
source:'printed', card:f.kull});
|
||||
game = advance(game, clean);
|
||||
assert.deepEqual(game.phaseBidPromises, pending.before);
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => act(game, f.actor, {type:'bid', amount:4, allyPayment:0}));
|
||||
assert.deepEqual(game, before);
|
||||
game = act(game, f.actor, {type:'bid', amount:3, allyPayment:0});
|
||||
assert.equal(game.auction?.bid, 3);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('countering Kull keeps a phase Yes for a real later lot', () => {
|
||||
const {f, game: source} = bound('yes');
|
||||
const pending = offered(source, f);
|
||||
const originalIndex = source.auction!.index;
|
||||
assert.ok(source.auction!.cards[originalIndex + 1], 'The real auction has another physical lot.');
|
||||
let game = act(pending.game, f.reactor, {type:'kullDecision', event:pending.reaction.event,
|
||||
source:'printed', card:f.kull});
|
||||
assert.equal(game.pendingKull?.stage, 'counter');
|
||||
assert.ok(viewGame(game, f.actor).responseControls?.cancelCards?.includes(f.karama));
|
||||
game = act(game, f.actor, {type:'card', mode:'cancel', card:f.karama});
|
||||
game = advance(game, state => clean(state) && !!state.auction &&
|
||||
state.auction.index > originalIndex && state.auction.active === f.actor);
|
||||
const promise = game.phaseBidPromises?.find(row => row.player === f.actor);
|
||||
assert.ok(promise && !promise.fulfilled && !promise.released,
|
||||
'A real future lot keeps the original phase-wide answer binding.');
|
||||
assert.ok(player(game, f.counterOwner).hand.some(card => card.id === pending.lot));
|
||||
const guide = viewGame(game, f.actor).phaseBidPromiseGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type, 'bid'); assert.equal(guide.action.amount, 4);
|
||||
game = act(game, f.actor, guide.action);
|
||||
assert.ok(game.phaseBidPromises?.some(row => row.player === f.actor && row.fulfilled));
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
@@ -31,7 +31,7 @@ void test('public host lobby shows the explicitly selected Kull preview and its
|
||||
assert.equal(host.lobbyModules?.kullPreview,true);
|
||||
assert.match(html,/Kull Wahad · Basic development preview/);
|
||||
assert.match(html,/Printed Kull is enabled by default/);
|
||||
assert.match(html,/Phase-wide bid and combined turn-action Truthtrance questions are unavailable/);
|
||||
assert.match(html,/Combined turn-action Truthtrance questions are unavailable/);
|
||||
assert.match(html,/Unfunded non-CHOAM Karama-dependent bids remain deferred/);
|
||||
assert.match(html,/When Richese is seated, structured bid, alliance, Revival and bribe commitments are also unavailable/);
|
||||
const off = act(f.lobby,f.lobby.host,{type:'moduleOption',module:'kullPreview',enabled:false});
|
||||
@@ -39,7 +39,7 @@ void test('public host lobby shows the explicitly selected Kull preview and its
|
||||
assert.equal(unselected.lobbyModules?.kullPreview,false);
|
||||
assert.match(table(unselected),/Kull Wahad · Basic development preview/);
|
||||
assert.match(table(unselected),/The host has turned off the printed Kull preview/);
|
||||
assert.doesNotMatch(table(unselected),/Phase-wide bid and combined turn-action Truthtrance questions are unavailable/);
|
||||
assert.doesNotMatch(table(unselected),/Combined turn-action Truthtrance questions are unavailable/);
|
||||
const nonhost = table(viewGame(f.lobby,f.actor));
|
||||
assert.match(nonhost,/disabled=""[^>]*type="checkbox"|type="checkbox"[^>]*disabled=""/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
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 { RosterAlliance } from '../components/roster-alliance';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance';
|
||||
import { applyAction, createGame, newPlayer, viewGame, type Game } 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 fixture(staged: boolean): Game {
|
||||
const game = createGame('ROSTER', newPlayer('a', 'A', 'atreides'), false);
|
||||
game.players.push(newPlayer('b', 'B', 'emperor'), newPlayer('c', 'C', 'fremen'),
|
||||
newPlayer('d', 'D', 'harkonnen'));
|
||||
Object.assign(game, {status:'playing', phase:1, turn:2, nexus:true,
|
||||
order:['a', 'b', 'c', 'd'], ready:[]});
|
||||
if (staged) game.coalitionState = emptyCoalitionState();
|
||||
return game;
|
||||
}
|
||||
function pair(game: Game): Game {
|
||||
game = applyAction(game, 'a', {type:'alliance', target:'b'});
|
||||
return applyAction(game, 'b', {type:'alliance', target:'a'});
|
||||
}
|
||||
function rendered(game: Game, playerId: string): string {
|
||||
return renderToStaticMarkup(createElement(RosterAlliance, {game:viewGame(game, 'd'), playerId}));
|
||||
}
|
||||
function formedGroup(): Game {
|
||||
let game = pair(fixture(true));
|
||||
const group = coalitionOf(game.players, game.coalitionState, 'a'); assert.ok(group);
|
||||
game = applyAction(game, 'a', {type:'coalitionPropose', groupId:group.id,
|
||||
entrant:'c', expectedRevision:game.coalitionState!.revision});
|
||||
for (const id of ['b', 'c']) {
|
||||
const proposal = game.coalitionState!.proposal!;
|
||||
game = applyAction(game, id, {type:'coalitionAnswer', event:proposal.event,
|
||||
nexus:proposal.nexus, turn:proposal.turn, accept:true,
|
||||
expectedRevision:game.coalitionState!.revision});
|
||||
}
|
||||
game.nexus = false; game.phase = 5;
|
||||
return game;
|
||||
}
|
||||
|
||||
void test('the roster retains legacy pair labels and omits an unallied seat', () => {
|
||||
const game = pair(fixture(false));
|
||||
assert.match(rendered(game, 'a'), /Allied with (?:<!-- -->)?Emperor/);
|
||||
assert.match(rendered(game, 'b'), /Allied with (?:<!-- -->)?Atreides/);
|
||||
assert.equal(rendered(game, 'c'), '');
|
||||
});
|
||||
|
||||
void test('the public roster names both allies after a real staged group formation', () => {
|
||||
const game = formedGroup();
|
||||
// Membership is projected publicly even after the Nexus ends.
|
||||
assert.match(rendered(game, 'a'), /Allied with (?:<!-- -->)?Emperor and Fremen/);
|
||||
assert.match(rendered(game, 'b'), /Allied with (?:<!-- -->)?Atreides and Fremen/);
|
||||
assert.match(rendered(game, 'c'), /Allied with (?:<!-- -->)?Atreides and Emperor/);
|
||||
assert.equal(rendered(game, 'd'), '');
|
||||
});
|
||||
|
||||
void test('the actual game table roster displays public group membership after Nexus', () => {
|
||||
const game = formedGroup();
|
||||
const markup = renderToStaticMarkup(createElement(GameTable, {
|
||||
game:viewGame(game, 'd'), busy:false, send:async () => {}, onExit() {},
|
||||
}));
|
||||
assert.match(markup, /Allied with (?:<!-- -->)?Emperor and Fremen/);
|
||||
assert.match(markup, /Allied with (?:<!-- -->)?Atreides and Fremen/);
|
||||
assert.match(markup, /Allied with (?:<!-- -->)?Atreides and Emperor/);
|
||||
});
|
||||
|
||||
void test('the game table offers group Guild transport while an unrelated commitment hides group funding', () => {
|
||||
let game = createGame('GROUPTRANSPORTVIEW', newPlayer('g', 'Guild', 'guild'), false);
|
||||
game.players.push(newPlayer('e', 'Emperor', 'emperor'), newPlayer('f', 'Fremen', 'fremen'),
|
||||
newPlayer('h', 'Harkonnen', 'harkonnen'));
|
||||
Object.assign(game, {status:'playing', phase:1, turn:2, nexus:true, order:['g','e','f','h'],
|
||||
ready:[], storm:18, deck:baseDeck(), response:null, decision:null, phaseOpening:null});
|
||||
game.coalitionState = emptyCoalitionState();
|
||||
game = applyAction(game, 'g', {type:'alliance', target:'e'});
|
||||
game = applyAction(game, 'e', {type:'alliance', target:'g'});
|
||||
const group = coalitionOf(game.players, game.coalitionState, 'g'); assert.ok(group);
|
||||
game = applyAction(game, 'g', {type:'coalitionPropose', groupId:group.id, entrant:'f',
|
||||
expectedRevision:game.coalitionState!.revision});
|
||||
for (const id of ['e', 'f']) {
|
||||
const offer = game.coalitionState!.proposal!;
|
||||
game = applyAction(game, id, {type:'coalitionAnswer', event:offer.event, nexus:offer.nexus,
|
||||
turn:offer.turn, accept:true, expectedRevision:game.coalitionState!.revision});
|
||||
}
|
||||
Object.assign(game, {phase:5, nexus:false, active:'e', ready:[],
|
||||
movementRemaining:['e','g','f','h'], guildTimingGranted:false, guildTimingLocked:false});
|
||||
for (const player of game.players) {
|
||||
player.forces = {}; player.reserves = 20; player.tanks = 0; player.spice = 10;
|
||||
player.hand = []; player.traitors = []; player.traitorChoices = [];
|
||||
player.shipped = false; player.moved = 0;
|
||||
}
|
||||
game.players.find(player => player.id === 'e')!.forces = {'imperial_basin:10':3};
|
||||
game.players.find(player => player.id === 'e')!.reserves = 17;
|
||||
game.bribePromises = [{event:'other-player-no', player:'h', asker:'e', turn:game.turn,
|
||||
recipient:'f', minimum:1, answer:false, progress:0}];
|
||||
const view = viewGame(game, 'e');
|
||||
assert.equal(view.coalitionFunding, null);
|
||||
const markup = renderToStaticMarkup(createElement(GameTable, {
|
||||
game:view, busy:false, send:async () => {}, onExit() {},
|
||||
}));
|
||||
assert.match(markup, /<button\b[^>]*>(?:(?!<\/button>)[\s\S])*Guild transport(?:(?!<\/button>)[\s\S])*<\/button>/);
|
||||
});
|
||||
Reference in new issue
Block a user