Allow Emperor gift during Guild timing choice
Verify and publish checkpoint container / container (push) Successful in 11m8s

This commit is contained in:
Quintine committed 2026-10-29 06:13:25 +11:00
1 parent a9b67a89b3
commit 93b0146de9
9 files changed
+383 -2

No files matched your search

+32
View File
@@ -0,0 +1,32 @@
'use client';
import { useId, useState } from 'react';
import type { Action, GameView } from '@/game/engine';
/** The engine projects this opportunity only to the Emperor at a supported
* Guild timing decision. Its maximum already accounts for committed spice. */
export function EmperorTimingGift({ game, act, busy }: {
game: GameView;
act: (action: Action) => void;
busy: boolean;
}) {
const id = useId();
const [raw, setRaw] = useState('1');
const offer = game.emperorGuildTimingGift;
if (!offer) return null;
const amount = /^[1-9]\d*$/.test(raw) ? Number(raw) : NaN;
const canShare = !busy && Number.isSafeInteger(amount) && amount >= 1 && amount <= offer.maximum;
const recipient = game.players.find(player => player.id === offer.recipient)?.name ?? 'your ally';
return <section className="notice min-w-0 space-y-3 break-words" aria-label="Emperor spice gift">
<h3>Share spice with {recipient}</h3>
<p className="fine">The Guild timing decision resumes after the Karama response to your gift.</p>
<label htmlFor={`${id}-amount`}>Spice to share</label>
<input id={`${id}-amount`} type="number" inputMode="numeric" min={1} max={offer.maximum}
step={1} value={raw} disabled={busy} onChange={event => setRaw(event.target.value)} />
<button type="button" className="game-action" disabled={!canShare}
onClick={() => { if (canShare) act({type:'emperorGift', amount}); }}>
Share spice
</button>
</section>;
}
+2
View File
@@ -290,6 +290,7 @@ 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 { EmperorTimingGift } from './emperor-timing-gift';
import { NexusEmperorPayment } from './nexus-emperor-payment';
import { NexusEmperorSellerPurchase } from './nexus-emperor-seller-purchase';
import { NexusEmperorAmbassadorPurchase } from './nexus-emperor-ambassador-purchase';
@@ -3219,6 +3220,7 @@ export function GameTable({
? 'Ride the sandworm'
: 'Spoils of battle'}
</h2>
<EmperorTimingGift game={g} act={act} busy={busy} />
{g.decision.kind === 'capturedLeader' &&
(g.decision.controller ?? g.decision.owner) === me.id && (
<p className="notice">
+12 -2
View File
@@ -17,6 +17,16 @@ open mode gates.
## Current checkpoint and work
**Emperor gift during Advanced Guild timing — 29 October. Prototyped /
Partial.** A clean classic Advanced Guild timing decision now permits the
Emperor's allied spice gift, with the original decision intact through the
gift's Karama response. The owner-only control uses the current legal amount;
allow and cancellation, all minimal bot reactions, private amount and rendered
table controls pass in staged journeys. Nineteen related cases, TypeScript,
lint and the production build pass. Other interrupted parents and the full
any-time timing remain open.
[Scope](EMPEROR_ALLIANCE_TIMING.md#advanced-guild-timing-prototype--29-october-2026).
**Basic Semuta after Moritani Atomics — 29 October. Prototyped / Partial.**
Clean Basic shipment and movement arrivals offer Semuta the private random
discard batch without repeating casualties, hand penalties or the original
@@ -62,8 +72,8 @@ failure; the exception's cause was not recorded. Acceptance diagnostics now
distinguish request exceptions from the last completed response and report only
sanitized details; the security checks are unchanged. Two focused diagnostic
cases pass. Run #86 succeeded; run #87 was canceled before starting and run
#88 for the Auditor checkpoint was running when checked. The updated diagnostics
remain awaiting their first executed CI verification.
#88 for the Auditor checkpoint succeeded with the updated diagnostics. Run #89
was canceled before starting and run #90 was running at the latest check.
**Truthtrance ally funding — 29 October. Prototyped / Partial.**
Supported quiet Basic Bidding and Shipment/Movement questions can bind a future
+22
View File
@@ -0,0 +1,22 @@
# Emperor alliance gift timing
## Advanced Guild timing prototype — 29 October 2026
The user-authorized revised rules, physical p. 31 (printed p. 29), allow the
Emperor to transfer spice to an ally at any time and for any reason. In a
clean classic-faction Advanced game, an Emperor can now make that gift while
the Guild's normal Shipment and Movement timing choice is pending. The choice
remains in place while the gift's ordinary Karama response resolves. Allowing
the gift transfers the selected uncommitted spice once; cancellation transfers
none. Either outcome leaves the same Guild decision and movement order.
The owner-only control uses the server's current amount and ally quote. The
initial scope excludes optional modules, competing continuation windows and
live commitments requiring a combined planner. The pre-existing quiet-window
gift remains unchanged. Other decision and response parents and the source's
complete any-time scope remain unfinished; this prototype does not certify
Advanced play or deployed behavior.
[Runtime cases](../tests/emperor-guild-timing-gift.test.ts) start a real
Advanced game and stage the alliance and movement boundary; [rendered controls](../tests/emperor-timing-gift-controls.test.tsx)
cover the actual table and owner-only control.
+8
View File
@@ -1,5 +1,13 @@
# Rule decision index
## Advanced Emperor gift during Guild timing — 29 October 2026
The authorized revised Emperor sheet, physical p. 31, allows allied spice
transfers at any time. A first clean Guild-timing interruption now preserves
the original choice through the gift's ordinary Karama response; other
interrupted parents remain unfinished.
[Scope](EMPEROR_ALLIANCE_TIMING.md#advanced-guild-timing-prototype--29-october-2026).
## Semuta after Moritani Atomics — 29 October 2026
The simultaneous random excess-card discards caused by a clean Basic Atomics
+65
View File
@@ -1232,6 +1232,8 @@ type SpiceBankerIncomePayment = {
};
export type ResponseWindow = {
/** The original public Guild timing decision remains live during this gift. */
emperorGuildTimingGift?: { event: string; signature: string };
automaticAdvisorFlip?: AutomaticAdvisorFlipSource;
advisorPreparationSource?: AdvisorPreparationSource;
advisorMatchingSource?: AdvisorMatchingSource;
@@ -19355,6 +19357,7 @@ function validateKaramaUse(
const kullBlock = kullKaramaBlock(g, p, karamaUsePurpose(g, use));
requireRule(!kullBlock, kullBlock ?? 'Kull Wahad prevents this Karama effect.');
if (use.kind === 'cancel') {
validateEmperorGuildTimingGift(g, use.response);
requireRule(
use.response && use.response.owner !== p.id,
'Karama cancels another faction’s power.',
@@ -29278,6 +29281,51 @@ function movementTurn(g: Game) {
};
} else g.active = remaining[0];
}
function emperorGuildTimingSignature(g: Game, owner: string, recipient: string, amount: number): string {
return JSON.stringify([g.turn, g.phase, g.active, g.decision, g.order,
g.movementRemaining, g.guildTimingGranted, g.guildTimingLocked, g.saphoMovementLast ?? null,
owner, recipient, amount]);
}
/** First supported interrupt: the clean Advanced Guild timing choice. */
function emperorGuildTimingGiftQuote(g: Game, p: Player): {recipient: string; maximum: number} | null {
const decision = g.decision;
if (!g.advanced || g.status !== 'playing' || g.phase !== 5 ||
g.expansions.length !== 0 || g.players.some(seat => !['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen'].includes(seat.faction)) ||
g.techTokens || g.strongholdCards || g.leaderSkills || g.homeworlds || g.nexusCards ||
g.discoveryEnabled || g.sandtrout || g.coalitionState || g.response || g.phaseOpening ||
g.truthtrance || g.pendingKarama || g.pendingTreacheryDiscard || g.battle ||
g.pendingRicheseGift || g.pendingNullentropy || g.pendingShipment ||
g.pendingGuildTransport || g.pendingArrivalOverlap || g.pendingAmbassador ||
decision?.kind !== 'guildTiming' || g.active !== null ||
p.faction !== 'emperor' || !p.ally ||
!g.players.some(seat => seat.id === p.ally && seat.ally === p.id) ||
liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length ||
hasMovementPromises(g, p.id, true) || hasBribePromises(g, p.id, true) ||
liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).some(row => row.answer) ||
liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).some(row => row.answer) ||
liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], p.id, g.turn).some(row => row.answer) ||
liveTurnActionPromises(g.turnActionPromises ?? [], p.id, g.turn).length ||
livePhaseBidPromises(g.phaseBidPromises ?? [], p.id, g.turn).some(row => row.answer)) return null;
const maximum = Math.max(0, uncommittedSpice(g, p));
return maximum > 0 ? { recipient: p.ally, maximum } : null;
}
function validateEmperorGuildTimingGift(g: Game, response: ResponseWindow) {
const parent = response.emperorGuildTimingGift;
if (!parent) return;
const owner = g.players.find(player => player.id === response.owner);
const recipient = g.players.find(player => player.id === response.recipient);
requireRule(response.kind === 'emperorGift' && typeof parent.event === 'string' && !!parent.event &&
owner?.faction === 'emperor' && recipient && owner.ally === recipient.id && recipient.ally === owner.id &&
typeof response.amount === 'number' && Number.isSafeInteger(response.amount) && response.amount > 0 &&
response.amount <= uncommittedSpice(g, owner) && g.decision?.kind === 'guildTiming' &&
parent.signature === emperorGuildTimingSignature(g, owner.id, recipient.id, response.amount),
'The Emperor gift lost its original Guild timing decision.');
}
function emperorGuildTimingGiftIntegrity(g: Game) {
if (g.response) validateEmperorGuildTimingGift(g, g.response);
if (g.pendingKarama?.use.kind === 'cancel')
validateEmperorGuildTimingGift(g, g.pendingKarama.use.response);
}
function chooseGuildTiming(g: Game, take: boolean) {
const guild = byFaction(g, 'guild')!;
if (take) g.active = guild.id;
@@ -37045,6 +37093,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
karamaConversionIntegrity(g);
currentFactionPayment(g);
const response = g.response!;
validateEmperorGuildTimingGift(g, response);
const advisorPreparationSource = currentAdvisorPreparationResponse(g, response);
const automaticAdvisorFlip = response.automaticAdvisorFlip ? currentAutomaticAdvisorFlip(g, response) : null;
const fremenGrant = response.kind === 'fremenRevivalGrant' ? currentFremenRevivalGrant(g, response) : null;
@@ -41879,6 +41928,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
* automatic deal payment; it is never supplied by a client action. */
function applyActionWithInitiator(state: Game, id: string, action: Action, initiator: string,
options?: {insolventDealPayment?: boolean}): Game {
emperorGuildTimingGiftIntegrity(state);
twoPlayerControllersIntegrity(state);
factionDraftIntegrity(state);
factionSwapIntegrity(state);
@@ -42623,6 +42673,7 @@ function settleAutomaticContinuations(g: Game) {
}
/** Internal authoritative continuation. Callers must persist with their usual CAS fence. */
export function normalizeAutomaticGame(state: Game): Game {
emperorGuildTimingGiftIntegrity(state);
twoPlayerControllersIntegrity(state);
factionDraftIntegrity(state);
factionSwapIntegrity(state);
@@ -43357,6 +43408,17 @@ function applyActionInner(
specialKarama(g, p, action);
return g;
}
if (t === 'emperorGift' && g.decision?.kind === 'guildTiming' && !g.response) {
const quote = emperorGuildTimingGiftQuote(g, p);
requireRule(quote && Object.keys(action).sort().join(',') === 'amount,type',
'This Guild timing gift is unavailable or no longer current.');
const amount = integer(action.amount, 1, quote.maximum, 'Spice');
g.response = { kind: 'emperorGift', owner: id, recipient: quote.recipient,
amount, elite: 0, passed: [], emperorGuildTimingGift: {
event: crypto.randomUUID(), signature: emperorGuildTimingSignature(g, id, quote.recipient, amount),
} };
return g;
}
if (g.response) {
const response = g.response;
if (response.kind === 'fremenMovement') pendingFremenMovement(g, response);
@@ -47143,6 +47205,7 @@ function applyActionInner(
throw new RuleError('That action is not available.');
}
export function viewGame(state: Game, id: string, now?: number) {
emperorGuildTimingGiftIntegrity(state);
twoPlayerControllersIntegrity(state);
factionDraftIntegrity(state);
factionSwapIntegrity(state);
@@ -48194,6 +48257,7 @@ export function viewGame(state: Game, id: string, now?: number) {
![g.response.owner, g.response.recipient].includes(id)
? { amount: undefined }
: {}),
...(g.response.emperorGuildTimingGift ? { emperorGuildTimingGift: undefined } : {}),
}
: null,
paymentIncome: currentFactionPayment(g),
@@ -48259,6 +48323,7 @@ export function viewGame(state: Game, id: string, now?: number) {
? g.aid[me.ally].amount
: 0,
},
emperorGuildTimingGift: emperorGuildTimingGiftQuote(g, me),
coalitionEmperor: (() => {
const group = coalitionEmperorGroup(g, me);
return group ? {allies: group.members.filter(member => member !== id),
+1
View File
@@ -90,6 +90,7 @@ export const FACTION_RULES: Record<
'Spice gifts and extra revival uses have Karama response windows. Canceling a use spends neither the Emperor’s spice nor the extra-revival allowance.',
],
advanced: [
'In a clean classic-faction Advanced game, the Emperor can also give an ally spice while the Guild is choosing its turn timing. Resolve the gift’s Karama response before the same Guild choice continues. Other interrupted windows remain unfinished.',
'Five Sardaukar forces have a printed battle strength of two each, except against Fremen, where they count as ordinary forces.',
'Spice support applies per token. A supported Sardaukar contributes two strength; an unsupported Sardaukar contributes one, except when fighting Fremen.',
'Once per game during Revival, spend Karama to revive up to three of your own forces or one of your own dead leaders for free. These special returns do not use normal revival allowances, including the ordinary one-Sardaukar limit. With Homeworlds, a low Salusa prohibits free Sardaukar returns; normal forces and eligible leaders remain available.',
+161
View File
@@ -0,0 +1,161 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
const player = (g: Game, id: string) => g.players.find(p => p.id === id)!;
const copy = (g: Game): Game => JSON.parse(JSON.stringify(g));
/** A real Advanced public start and native setup. The reciprocal alliance and
* clean movement boundary are staged; no saved room or database is touched. */
function guildTiming() {
let g = createGame('EMPGUILDTIMING', newPlayer('e', 'Emperor', 'emperor'), true);
joinGame(g, newPlayer('g', 'Guild', 'guild'));
joinGame(g, newPlayer('h', 'Harkonnen', 'harkonnen'));
for (const p of g.players) g = applyAction(g, p.id, {type:'ready'});
g = applyAction(g, 'e', {type:'start', advancedPreview:true});
for (let i = 0; i < 60 && g.status === 'setup'; i++) {
const step = nextStrongholdFactionsNativeStep(g);
assert.ok(step); g = applyAction(g, step.actor, step.action);
}
assert.equal(g.status, 'playing');
player(g, 'e').ally = 'g'; player(g, 'g').ally = 'e';
// Select the legitimate phase boundary that movementTurn would produce.
Object.assign(g, {
phase:5, turn:1, order:['e','g','h'], active:null,
movementRemaining:['e','g','h'], guildTimingGranted:false, guildTimingLocked:false,
decision:{kind:'guildTiming', player:'g', next:'e', following:'e'},
response:null, phaseOpening:null, ready:[],
});
assert.equal(g.decision?.kind, 'guildTiming');
return g;
}
function allow(g: Game) {
for (let i = 0; g.response && i < 15; i++) {
const actor = g.players.find(p => !g.response!.passed.includes(p.id));
assert.ok(actor); g = applyAction(g, actor.id, {type:'passResponse'});
}
assert.equal(g.response, null);
return g;
}
function parent(g: Game) {
return {decision:structuredClone(g.decision), phase:g.phase, turn:g.turn,
active:g.active, order:[...g.order], movementRemaining:structuredClone(g.movementRemaining),
guildTimingGranted:g.guildTimingGranted, guildTimingLocked:g.guildTimingLocked};
}
function give(g: Game, amount = 2) {
return applyAction(g, 'e', {type:'emperorGift', amount});
}
function setAsideKaramas(g: Game) {
for (const p of g.players) {
const cards = p.hand.filter(card => card.effect === 'karama');
p.hand = p.hand.filter(card => card.effect !== 'karama');
g.deck.push(...cards);
}
}
function holdCounter(g: Game) {
setAsideKaramas(g);
const index = g.deck.findIndex(card => card.effect === 'karama');
assert.ok(index >= 0);
const [card] = g.deck.splice(index, 1);
player(g, 'h').hand.push(card);
return card.id;
}
function guildChooses(g: Game) {
assert.equal(g.decision?.kind, 'guildTiming');
g = applyAction(g, 'g', {type:'decision', take:true});
if (g.response) g = allow(g);
assert.equal(g.active, 'g');
assert.equal(g.phase, 5);
return g;
}
void test('Advanced Guild timing shows only the Emperor a legal gift and resumes the exact choice after allowing', () => {
let g = guildTiming();
holdCounter(g);
const original = parent(g), emperor = player(g, 'e').spice, guild = player(g, 'g').spice;
assert.deepEqual(viewGame(g, 'e').emperorGuildTimingGift, {recipient:'g', maximum:emperor});
assert.equal(viewGame(g, 'g').emperorGuildTimingGift, null);
assert.equal(viewGame(g, 'h').emperorGuildTimingGift, null);
g = give(g);
assert.equal(g.response?.kind, 'emperorGift');
assert.equal(player(g, 'e').spice, emperor);
assert.equal(player(g, 'g').spice, guild);
assert.equal(viewGame(g, 'e').emperorGuildTimingGift, null);
g = allow(copy(g));
assert.deepEqual(parent(g), original);
assert.equal(player(g, 'e').spice, emperor - 2);
assert.equal(player(g, 'g').spice, guild + 2);
guildChooses(g);
});
void test('a real Karama cancels the gift and restores the same Guild timing decision', () => {
let g = guildTiming();
const counter = holdCounter(g), original = parent(g);
const emperor = player(g, 'e').spice, guild = player(g, 'g').spice;
g = give(g, 3);
assert.equal(g.response?.kind, 'emperorGift');
g = applyAction(g, 'h', {type:'card', card:counter, mode:'cancel'});
assert.equal(g.response, null);
assert.deepEqual(parent(g), original);
assert.equal(player(g, 'e').spice, emperor);
assert.equal(player(g, 'g').spice, guild);
assert.equal(g.discard.filter(card => card.id === counter).length, 1);
guildChooses(g);
});
void test('other players and unfunded or invalid gifts cannot disturb the pending Guild decision', () => {
const g = guildTiming(), maximum = player(g, 'e').spice;
const attempts: [string, Action][] = [
['g', {type:'emperorGift', amount:1}], ['h', {type:'emperorGift', amount:1}],
['e', {type:'emperorGift', amount:0}], ['e', {type:'emperorGift', amount:maximum + 1}],
];
for (const [actor, action] of attempts) {
const before = copy(g);
assert.throws(() => applyAction(g, actor, action));
assert.deepEqual(g, before);
}
const unallied = copy(g);
player(unallied, 'e').ally = null; player(unallied, 'g').ally = null;
const before = copy(unallied);
assert.equal(viewGame(unallied, 'e').emperorGuildTimingGift, null);
assert.throws(() => give(unallied));
assert.deepEqual(unallied, before);
});
void test('the pending gift remains bound to its original amount and Guild timing decision', () => {
const original = guildTiming();
holdCounter(original);
const pending = give(original, 2);
const changedAmount = copy(pending);
assert.ok(changedAmount.response);
changedAmount.response.amount = 1;
assert.throws(() => viewGame(changedAmount, 'g'));
assert.throws(() => applyAction(changedAmount, 'h', {type:'passResponse'}));
const changedDecision = copy(pending);
assert.ok(changedDecision.decision?.kind === 'guildTiming');
changedDecision.decision.next = 'h';
assert.throws(() => viewGame(changedDecision, 'e'));
assert.throws(() => applyAction(changedDecision, 'h', {type:'passResponse'}));
assert.equal(viewGame(pending, 'h').response?.amount, undefined);
});
void test('all four minimal bot profiles can resolve the gift response legally', () => {
let g = guildTiming();
holdCounter(g);
const original = parent(g);
g = give(g, 1);
for (const difficulty of DIFFICULTIES) {
let trial = copy(g);
const view = viewGame(trial, 'h');
view.players.find(p => p.id === 'h')!.bot = difficulty;
const proposed = botActions(view);
assert.deepEqual(proposed, [{type:'passResponse'}]);
trial = applyAction(trial, 'h', proposed[0]);
trial = allow(trial);
assert.deepEqual(parent(trial), original);
guildChooses(trial);
}
});
@@ -0,0 +1,80 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game, type GameView } from '../game/engine';
import { EmperorTimingGift } from '../components/emperor-timing-gift';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
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();
/** A public Advanced start and native setup, followed by a staged mutual
* alliance and clean Guild timing boundary, as in the engine journey. */
function fixture(): Game {
let game = createGame('EMPGIFTCONTROL', newPlayer('e','Emperor','emperor'), true);
joinGame(game, newPlayer('g','Guild','guild'));
joinGame(game, newPlayer('h','Harkonnen','harkonnen'));
for (const player of game.players) game = applyAction(game, player.id, {type:'ready'});
game = applyAction(game, 'e', {type:'start', advancedPreview:true});
for (let step = 0; game.status === 'setup' && step < 60; step++) {
const next = nextStrongholdFactionsNativeStep(game); assert.ok(next);
game = applyAction(game, next.actor, next.action);
}
assert.equal(game.status, 'playing');
game.players.find(player => player.id === 'e')!.ally = 'g';
game.players.find(player => player.id === 'g')!.ally = 'e';
Object.assign(game, {phase:5, turn:1, order:['e','g','h'], active:null,
movementRemaining:['e','g','h'], guildTimingGranted:false, guildTimingLocked:false,
decision:{kind:'guildTiming', player:'g', next:'e', following:'e'},
response:null, phaseOpening:null, ready:[]});
return game;
}
const component = (game: GameView, busy = false) =>
renderToStaticMarkup(createElement(EmperorTimingGift, {game, busy, act() {}}));
const table = (game: GameView, busy = false) =>
renderToStaticMarkup(createElement(GameTable, {game, busy, send:async () => {}, onExit() {}}));
void test('quoted Guild timing gift shows the ally, amount bounds and explanation', () => {
const game = fixture(), view = viewGame(game, 'e');
assert.deepEqual(view.emperorGuildTimingGift, {recipient:'g', maximum:10});
const html = component(view);
assert.match(html, /Share spice with Guild/);
assert.match(html, /Guild timing decision resumes after the Karama response/);
assert.match(html, /Spice to share/);
assert.match(html, /type="number"[^>]*min="1"[^>]*max="10"/);
assert.match(html, /<button\b(?![^>]*disabled)[^>]*>Share spice<\/button>/);
});
void test('the gift component hides absent quotes and disables submission while busy', () => {
const game = fixture(), own = viewGame(game, 'e'), other = viewGame(game, 'h');
assert.equal(other.emperorGuildTimingGift, null);
assert.equal(component(other), '');
const busy = component(own, true);
assert.match(busy, /<button\b[^>]*disabled=""[^>]*>Share spice<\/button>/);
assert.match(busy, /<input\b[^>]*disabled=""/);
});
void test('the real Guild timing table exposes the gift only to the Emperor', () => {
const game = fixture(), own = viewGame(game, 'e'), guild = viewGame(game, 'g'), observer = viewGame(game, 'h');
assert.equal(game.decision?.kind, 'guildTiming');
assert.match(table(own), /Share spice with Guild/);
assert.match(table(own), /Guild timing decision resumes after the Karama response/);
assert.doesNotMatch(table(guild), /Share spice with Guild/);
assert.doesNotMatch(table(observer), /Share spice with Guild/);
assert.match(table(own, true), /<button\b[^>]*disabled=""[^>]*>Share spice<\/button>/);
});