Allow retrying canceled Richese gifts
This commit is contained in:
1 parent
e5c7e338e8
commit
813a19bb1d
12 files changed
+156
-33
No files matched your search
@@ -20,6 +20,19 @@ open mode gates.
|
||||
Keep identified Basic-function gaps ahead of Advanced and wider interactions.
|
||||
Prototype labels and final acceptance gates remain.
|
||||
|
||||
**Richese canceled gift retry — 27 October. Prototyped / Partial.**
|
||||
A canceled card may be offered again in the same phase under a provisional
|
||||
one-use interpretation. Each declaration has its own response, and the card,
|
||||
ally, capacity and commitments are checked again. Existing cancellation history
|
||||
remains compatible and no longer blocks the fresh offer. The private control and
|
||||
minimal bot use the same legal-card projection. [Source and boundary](RICHESE_GIFT_ENGINE_AUDIT.md#canceled-gift-retry--27-october-2026).
|
||||
|
||||
Six focused files pass 50/50. A controlled Basic fixture exercises five engine
|
||||
actions and three actual server-rendered gift-button callbacks: two physical
|
||||
Karama cancellations and a successful third offer. The fixture stages its phase,
|
||||
alliance, cards and parent decision; it is not an earned game history or a
|
||||
browser/mobile/deployed check. Complete Richese modes remain uncertified.
|
||||
|
||||
**Emperor Nexus positive shortages — 27 October. Prototyped / Partial.**
|
||||
Cunning uses five eligible ordinary counters when possible, otherwise the whole
|
||||
positive short battle group. Secret Ally revival takes three when eligible,
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 27 October 2026 — Richese canceled gift retry
|
||||
|
||||
Prototype the Basic alliance gift retry under the standing selected-interpretation
|
||||
authority: a canceled offer ends, but the retained card may be reoffered in the
|
||||
same phase with a fresh response and ordinary legality checks. Preserve the
|
||||
legacy unique cancellation history for compatibility. This is a provisional
|
||||
composition, not a published gift-specific retry ruling. Complete interaction,
|
||||
mobile, deployed and full-mode acceptance remain open.
|
||||
[Contract](RICHESE_GIFT_ENGINE_AUDIT.md#canceled-gift-retry--27-october-2026).
|
||||
|
||||
Six focused files pass 50/50. A controlled Basic fixture stages its phase,
|
||||
alliance, hands and parent decision, then drives five actual engine actions and
|
||||
three server-rendered controller callbacks: two distinct physical Karama
|
||||
cancellations followed by a successful gift. This is not native prior chronology,
|
||||
browser hydration, mobile or deployment evidence. Private runtime evidence is
|
||||
`20261027-richese-gift-retry-runtime/run-v1/`.
|
||||
|
||||
## 27 October 2026 — Emperor Nexus positive shortages
|
||||
|
||||
Prototype the selected shortage-only composition: Cunning uses the full five
|
||||
|
||||
@@ -6,6 +6,27 @@ Initial review 2026-09-06, before runtime gift integration; followed below by an
|
||||
|
||||
The publisher's CHOAM & Richese rulebook, p6, permits Richese to give an ally a Richese Treachery Card from Richese's hand at any time when the ally's hand is not full. It is a basic alliance ability, separate from special Karama acquisition and the cache auction. It does not create a cache-to-ally transfer, an exchange requirement, payment, a once-per-turn limit, or another faction's gift power. Canonical inventory recognition is already provided by `richeseCardDefinition` in `game/richese-cards.ts`.
|
||||
|
||||
## Canceled gift retry — 27 October 2026
|
||||
|
||||
**Prototyped / Partial.** Under the standing selected-interpretation authority,
|
||||
the cancellation prevents that one offer, and Richese may offer the retained
|
||||
card again in the same phase if the current alliance, capacity, ownership and
|
||||
commitments permit it. Each new declaration receives a fresh response and can
|
||||
itself be canceled. The Basic gift's any-time wording and the updated physical
|
||||
Karama's one-use cancellation support this composition; no gift-specific
|
||||
publisher retry ruling has been found. The old `richeseGiftBlocked` field stays
|
||||
as unique per-phase cancellation history for compatible saved state and source
|
||||
signatures, not a legality limit. The existing private control and legal bot
|
||||
selection consume the server's updated card quote. Other interactions and full
|
||||
rules acceptance remain open.
|
||||
|
||||
Focused validation covers fresh responses and two distinct printed counters,
|
||||
parent restoration, unique cancellation history, custody and all four minimal
|
||||
profiles. A controlled staged Basic fixture additionally drives five engine
|
||||
actions and three actual server-rendered gift-control callbacks through two
|
||||
cancellations and eventual transfer. It does not establish native prior
|
||||
chronology, browser hydration, mobile or deployed behavior.
|
||||
|
||||
Source: [GF9 CHOAM & Richese rules, p6](https://www.gf9games.com/dune/wp-content/uploads/2021/11/CHOAM-Rulebook-low-res.pdf), verified in the official indexed PDF and the publisher-text extraction `/tmp/dune-rules/choam-lelekan-mirror.txt`. The [GF9 FAQ](https://www.gf9games.com/dune/wp-content/uploads/2020/04/Dune-FAQ.pdf), p1, expressly includes alliance abilities under Karama cancellation. Its older phase-duration wording must not override the updated physical Karama card's one-use wording. The R2 p12 Richese cancellation table lists auction, No-Field and Black Market, but does not expressly exempt the gift. Applying the general alliance-cancellation rule to gift is supported composition; no retrieved gift-specific FAQ defines the exact nested-response sequence.
|
||||
|
||||
The isolated `game/richese-gift.ts` validates mutual current alliance, canonical physical hand ownership and supplied current capacity. It returns a normalized identity-bound intent and cloned replacement hands. It deliberately does not validate game timing, battle promises, or reserved cards. Those are authoritative engine responsibilities.
|
||||
@@ -86,11 +107,11 @@ Public response and logs should identify the donor and gift action, not the chos
|
||||
|
||||
The exact pending card is reserved against further voluntary spending/removal. The Harkonnen random-hand path rejects extraction of the reserved donor hand before RNG, rather than secretly changing its distribution. Sealed plans, fixed prescience, played-card retention, Black Market stock and accepted replacement cards remain reserved. Gifts preserve winning purchase capacity and committed discard/exchange feasibility. Harkonnen bonus resolution now rechecks capacity instead of silently granting a ninth card. Completed recipient plans and historical private inspections remain unchanged. These changes address the initial hazards above without replacing the underlying source with a blanket ban on gifts during other decisions.
|
||||
|
||||
**Cancellation interpretation still unresolved.** The engine records the canceled physical card for the current turn/phase and blocks repeating that same gift until the phase changes. This is a disclosed conservative implementation guard while repeat-use timing awaits a ruling; the source does not establish a once-per-phase limit on all gifts. Other cards are validated independently. It must not be described as a verified printed restriction.
|
||||
**Historical cancellation guard, superseded above.** The initial engine blocked the canceled physical card for the current turn/phase pending a repeat-use interpretation. This was an implementation guard, not a verified printed restriction.
|
||||
|
||||
**Player controls — Partial.** `components/richese-gift.tsx`, integrated into the private-hand console, gives the owner eligible-card selection, readable `CardRules`, full `CardInspector`, unavailable-card explanations and a single explicit Give button. It adds no confirmation modal or invented recipient-acceptance action. Pending identity is visible only to the two allies; the normal response panel resolves cancellation/allowance. Inspection changes no game state. Busy/blocked state prevents another declaration. Browser acceptance remains pending at this checkpoint.
|
||||
|
||||
**AI — Partial.** `game/bots.ts` gives only strict canonical Richese Karama when Bidding exposes a lower ally hand count and Richese needs space in a full own hand. It uses only owner legal choices and public counts, never ally contents; no outside-Bidding card need is inferred from absent counts. All four profiles preserve response/decision/Truthtrance priority and do not retry the canceled card. This deliberate policy does not imply full gift strategy or support for the other nine card effects. Those physical cards can be gifted, but their effect handlers remain unfinished.
|
||||
**AI — Partial.** `game/bots.ts` gives only strict canonical Richese Karama when Bidding exposes a lower ally hand count and Richese needs space in a full own hand. It uses only owner legal choices and public counts, never ally contents; no outside-Bidding card need is inferred from absent counts. All four profiles preserve response/decision/Truthtrance priority. The former no-retry behavior is superseded above. This policy does not imply full gift strategy or support for every card interaction.
|
||||
|
||||
**Documentation — Partial.** The in-app `richese-gift` topic records five separate facets and links to the collection, acquisition, alliances, Karama and Truthtrance topics. The acquisition topic now points to the integrated gift control while retaining the unfinished Distrans/Nullentropy search boundary. Primary-source and historical audit statements above remain distinguishable from later implementation choices. Full Richese start gates remain closed.
|
||||
|
||||
@@ -100,6 +121,6 @@ The exact pending card is reserved against further voluntary spending/removal. T
|
||||
|
||||
The completed registered suites pass 1,230/1,230 rules/client and 93/93 persisted/API scenarios. TypeScript, lint and production build pass. Gift-specific checks comprise six pure custody, five engine matrix/privacy/recovery cases, eight independent review, four all-profile AI and three production SQLite/API recovery cases. The five engine cases include all ten canonical identities in both Basic and Advanced across all nine phases. The independent stale-alliance regression caught an actual hand overwrite; exact original intent validation now aborts safely before either hand assignment.
|
||||
|
||||
Desktop browser QA in synthetic QPNV5XJJ completed an uncontested transfer, reopened a separate contested gift through a fresh invitation tab, and displayed the recipient’s private pending card without adding it to their hand or asking for redundant acceptance. Synthetic role swapping was used only to inspect the recipient interface, not to claim real seat-switch recovery. Mobile, full Richese games and the unanswered same-card cancellation retry remain unverified. Full logs and saved fixture versions are recorded in IMPLEMENTATION_STATUS.md; no expansion gate was removed.
|
||||
Desktop browser QA in synthetic QPNV5XJJ completed an uncontested transfer, reopened a separate contested gift through a fresh invitation tab, and displayed the recipient’s private pending card without adding it to their hand or asking for redundant acceptance. Synthetic role swapping was used only to inspect the recipient interface, not to claim real seat-switch recovery. At that checkpoint, mobile, full Richese games and same-card cancellation retry were unverified; the later provisional retry evidence is recorded above. Full logs and saved fixture versions are recorded in IMPLEMENTATION_STATUS.md; no expansion gate was removed.
|
||||
|
||||
The final follow-up review also covers the mandatory Harkonnen hand-exchange return: an incoming gift may not consume its reserved target slots. A real take/gift/return fixture checks both blocked-full and permitted-spare-capacity cases, completing after JSON reload with conserved cards. Six additional registered pure Distrans tests contribute to the current full-suite total but do not activate that next card effect.
|
||||
@@ -1,5 +1,14 @@
|
||||
# Rule decision index
|
||||
|
||||
## Richese canceled gift retry — 27 October 2026
|
||||
|
||||
Provisionally allow a fresh offer of the same retained Richese card in the same
|
||||
phase after one gift was canceled, with a new Karama response each time. The
|
||||
Basic gift says any time and the updated physical Karama cancels one use; no
|
||||
gift-specific publisher retry ruling was found. Revalidate the current alliance,
|
||||
custody, capacity and commitments. Retain the legacy unique cancellation list
|
||||
as history rather than a repeat-use prohibition. [Source and scope](RICHESE_GIFT_ENGINE_AUDIT.md#canceled-gift-retry--27-october-2026).
|
||||
|
||||
## Emperor Nexus positive short groups — 27 October 2026
|
||||
|
||||
Select the provisional positive-only shortage composition under standing
|
||||
|
||||
@@ -5343,14 +5343,6 @@ function validateRicheseGift(g: Game, owner: Player, cardId: string) {
|
||||
const card = owner.hand.find((c) => c.id === cardId)!;
|
||||
const reservation = transferCardBlock(g, owner, card);
|
||||
requireRule(!reservation, reservation ?? 'This card is already committed.');
|
||||
requireRule(
|
||||
!(
|
||||
g.richeseGiftBlocked?.turn === g.turn &&
|
||||
g.richeseGiftBlocked.phase === g.phase &&
|
||||
g.richeseGiftBlocked.cards.includes(cardId)
|
||||
),
|
||||
'Retrying this canceled card’s gift in the same phase awaits a ruling.',
|
||||
);
|
||||
const resulting = transferRicheseGift(
|
||||
owner,
|
||||
recipient,
|
||||
|
||||
+3
-3
@@ -690,7 +690,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'An ordinary decision, another response or a phase-opening window can be suspended while the gift resolves, then restored with its existing choices and passes. An active Truthtrance question must finish first. A newly received Karama may be used in a restored response that is still open, under the normal response rules.',
|
||||
'You cannot give a card committed to a sealed plan, fixed prescience answer, unresolved played-card retention, unsold Black Market lot or accepted replacement. Gifts must preserve binding battle promises and enough resources for committed exchanges. Your ally must retain space for an unsettled auction commitment and the mandatory return from a Harkonnen hand exchange; completed plans and private inspection snapshots do not change when a gift arrives.',
|
||||
'The server checks alliance, exact card ownership, capacity and commitments again before transfer. An obsolete pending gift aborts without a partial transfer and restores its interrupted flow. Unrelated players see the gift power, not its private card identity. A pending card cannot also be spent, exchanged or randomly extracted.',
|
||||
'After cancellation, trying the same card again in the same phase is currently blocked pending a ruling on repeated use. This is an explicit implementation guard, not a printed once-per-phase limit on all gifts. Other legally available cards are assessed separately.',
|
||||
'After a Karama cancellation, the same card may be offered again while it remains legal. Each new offer opens a fresh cancellation response. This is a provisional interpretation of the one-use cancellation and the gift’s any-time wording, not a specific published retry ruling.',
|
||||
'All four AI profiles conservatively give only the canonical Richese Karama during Bidding to free a full hand when the ally’s public card count is lower and there is room. They do not inspect ally hand contents or infer an unknown need for a card. Pending responses, decisions and Truthtrance take priority.',
|
||||
'All ten canonical Richese cards may be transferred when legal, and Karama has its existing card-effect handler. Distrans supports its separate card-transfer effect, Nullentropy Box supports paid private discard search, Ornithopter supports its two movement modes, and Residual Poison supports a random opposing leader death before leader commitment. Portable Snooper supports ordinary and late poison defense. Stone Burner supports a guarded weapon commitment and revealed leader-effect choice. Mirror Weapon copies an opposing revealed weapon in bounded CHOAM/Richese-deck classic-faction battles, with copy-first Tooth/Stone choices; physical-Mirror winner retention even after copied activated Tooth/Artillery is a user-selected interpretation. Juice of Sapho supports bounded Once Around and movement ordering. Semuta Drug has a bounded clean ordinary-discard preview; other discard producers and Sapho timing modes remain unfinished. Desktop gift transfer and fresh-tab pending recovery have been checked in a synthetic table. Mobile, broader interactions and full Richese games remain pending; Richese starts remain disabled.',
|
||||
],
|
||||
@@ -707,7 +707,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
area: 'Implementation',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'Canonical hand-to-ally transfer, private reservation, Karama cancellation, suspended continuation restoration and commitment/capacity revalidation are integrated in development fixtures. Same-card same-phase retry after cancellation awaits a ruling; complete Richese interactions remain unfinished.',
|
||||
'Canonical hand-to-ally transfer, private reservation, Karama cancellation, suspended continuation restoration and commitment/capacity revalidation are integrated. Reoffering a canceled card is provisionally allowed with a fresh response; complete Richese interactions remain unfinished.',
|
||||
},
|
||||
{
|
||||
area: 'Player controls',
|
||||
@@ -719,7 +719,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
area: 'AI',
|
||||
status: 'Partial',
|
||||
detail:
|
||||
'All four profiles make a bounded canonical-Karama gift to free a full bidding hand using public ally counts and owner-only legal choices. Other card effects and broader gift strategy are unfinished; canceled gifts are not automatically retried.',
|
||||
'All four profiles make a bounded canonical-Karama gift to free a full bidding hand using public ally counts and owner-only legal choices. They can retry a canceled gift when that same legal condition remains. Other card effects and broader gift strategy are unfinished.',
|
||||
},
|
||||
{
|
||||
area: 'Documentation',
|
||||
|
||||
@@ -437,16 +437,17 @@ function calculate(
|
||||
(uniqueStrings(current.cards) &&
|
||||
current.cards.every((id) =>
|
||||
RICHESE_CARD_DEFINITIONS.some((d) => d.card.id === id),
|
||||
) &&
|
||||
!current.cards.includes(gift.intent.cardId)),
|
||||
'The canceled gift card was already blocked or its current blocked list is malformed.',
|
||||
)),
|
||||
'The current canceled gift history is malformed.',
|
||||
);
|
||||
return {
|
||||
kind: 'richeseGift',
|
||||
blocked: {
|
||||
turn: g.turn,
|
||||
phase: g.phase,
|
||||
cards: [...(current?.cards ?? []), gift.intent.cardId],
|
||||
cards: current?.cards.includes(gift.intent.cardId)
|
||||
? [...current.cards]
|
||||
: [...(current?.cards ?? []), gift.intent.cardId],
|
||||
},
|
||||
resume: structuredClone(gift.resume),
|
||||
};
|
||||
|
||||
@@ -287,7 +287,7 @@ void test('denied gift cancellation does not demand present custody, spare recip
|
||||
assert.equal(next.discard.filter((c) => c.id === f.gift.id).length, 1);
|
||||
assert.equal(player(next, 'h').hand.length, 9);
|
||||
});
|
||||
void test('current blocked gifts append once while a prior-phase blocked list is replaced', () => {
|
||||
void test('gift cancellation history remains unique on a repeated gift and prior-phase history is replaced', () => {
|
||||
const f = gift();
|
||||
f.g.richeseGiftBlocked = {
|
||||
turn: 2,
|
||||
@@ -299,6 +299,11 @@ void test('current blocked gifts append once while a prior-phase blocked list is
|
||||
(quote(f.g) as { blocked: unknown }).blocked,
|
||||
{ turn: 2, phase: 5, cards: ['richese-semuta-drug', f.gift.id] },
|
||||
);
|
||||
f.g.richeseGiftBlocked.cards.push(f.gift.id);
|
||||
const repeated = quote(f.g);
|
||||
assert.ok(repeated.kind === 'richeseGift');
|
||||
assert.deepEqual(repeated.blocked.cards, ['richese-semuta-drug', f.gift.id]);
|
||||
assert.deepEqual(repeated.resume, f.g.pendingRicheseGift!.resume);
|
||||
f.g.richeseGiftBlocked.phase = 4;
|
||||
const q = quote(f.g);
|
||||
assert.ok(q.kind === 'richeseGift');
|
||||
@@ -366,7 +371,10 @@ void test('canceled receipts and suspended control corruptions reject before car
|
||||
};
|
||||
},
|
||||
(g) => {
|
||||
g.richeseGiftBlocked = { turn: 2, phase: 5, cards: [source.gift.id] };
|
||||
g.richeseGiftBlocked = { turn: 2, phase: 5, cards: [source.gift.id, source.gift.id] };
|
||||
},
|
||||
(g) => {
|
||||
g.richeseGiftBlocked = { turn: 2, phase: 5, cards: ['not-a-richese-card'] };
|
||||
},
|
||||
];
|
||||
const random = t.mock.method(crypto, 'getRandomValues', () => {
|
||||
|
||||
@@ -90,7 +90,7 @@ void test('all profiles retain their card without public hand-space evidence or
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
for (const change of [
|
||||
(v: GameView) => {
|
||||
v.richeseGift!.blocked = 'Gift canceled this turn.';
|
||||
v.richeseGift!.blocked = 'Resolve the pending decision first.';
|
||||
},
|
||||
(v: GameView) => {
|
||||
v.richeseGift!.cards = [];
|
||||
@@ -144,7 +144,7 @@ void test('gift policy uses strict eligible identities and never inspects an all
|
||||
}
|
||||
});
|
||||
|
||||
void test('gift cancellation retains custody without an automatic retry, and Truthtrance and decisions take priority', () => {
|
||||
void test('gift cancellation retains custody and profiles retry legally while Truthtrance and decisions take priority', () => {
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const g = fixture();
|
||||
const k = g.deck.find((c) => c.effect === 'karama')!;
|
||||
@@ -163,7 +163,14 @@ void test('gift cancellation retains custody without an automatic retry, and Tru
|
||||
true,
|
||||
);
|
||||
assert.equal(canceled.players[1].hand.length, 0);
|
||||
assert.equal(giftAction(projected(canceled, difficulty)), undefined);
|
||||
const retry = giftAction(projected(reload(canceled), difficulty));
|
||||
assert.ok(retry, difficulty);
|
||||
assert.equal(retry.card, 'richese-karama');
|
||||
const transferred = applyAction(reload(canceled), 'r', retry);
|
||||
assert.equal(transferred.response, null);
|
||||
assert.equal(transferred.players[0].hand.some((c) => c.id === retry.card), false);
|
||||
assert.deepEqual(transferred.players[1].hand.map((c) => c.id), [retry.card]);
|
||||
assert.equal(transferred.discard.some((c) => c.id === retry.card), false);
|
||||
const truth = projected(g, difficulty);
|
||||
truth.truthtrance = {
|
||||
stage: 'priority',
|
||||
|
||||
@@ -53,18 +53,54 @@ void test('pending exact gift is private to its two participants, never leaks ca
|
||||
assert.equal(next.log.some(entry => entry.text.includes(card.name) || entry.text.includes(card.id)), false);
|
||||
assert.deepEqual(next.players[1].hand, [card]);
|
||||
});
|
||||
void test('cancellation retains the exact card and parent decision; same-phase retry remains an explicit unresolved guard', () => {
|
||||
const g = fixture(); const card = take(g, 'richese-karama'), k = counter(g);
|
||||
void test('a canceled gift can be retried in the same phase, with a fresh counter window and original custody', () => {
|
||||
const g = fixture(); const card = take(g, 'richese-karama'), first = counter(g), second = counter(g);
|
||||
const parent = {kind: 'richeseDeclaration' as const, player: 'r'};
|
||||
g.decision = parent;
|
||||
const inventory = (state: Game) => [...state.deck, ...state.discard, ...state.richeseCache!, ...state.players.flatMap(p => p.hand)].map(c => c.id).sort();
|
||||
const original = inventory(g);
|
||||
const pending = gift(g, card.id);
|
||||
assert.equal(pending.decision, null);
|
||||
const next = applyAction(reload(pending), 'o', {type: 'card', mode: 'cancel', card: k.id});
|
||||
assert.deepEqual(next.decision, parent); assert.deepEqual(next.players[0].hand, [card]);
|
||||
assert.deepEqual(next.players[1].hand, []);
|
||||
assert.throws(() => gift(next, card.id), /awaits a ruling/);
|
||||
next.phase = 4; next.decision = null;
|
||||
assert.deepEqual(gift(next, card.id).players[1].hand, [card]);
|
||||
const canceled = applyAction(reload(pending), 'o', {type: 'card', mode: 'cancel', card: first.id});
|
||||
assert.deepEqual(canceled.decision, parent);
|
||||
assert.deepEqual(canceled.players[0].hand, [card]);
|
||||
assert.deepEqual(canceled.players[1].hand, []);
|
||||
assert.equal(canceled.discard.filter(c => c.id === first.id).length, 1);
|
||||
assert.ok(viewGame(canceled, 'r').richeseGift!.cards.some(c => c.id === card.id));
|
||||
const retry = gift(reload(canceled), card.id);
|
||||
assert.equal(retry.response?.kind, 'richeseGift');
|
||||
assert.notEqual(retry.pendingRicheseGift!.event, pending.pendingRicheseGift!.event);
|
||||
assert.deepEqual(retry.players[0].hand, [card]);
|
||||
assert.ok(retry.players[2].hand.some(c => c.id === second.id));
|
||||
assert.deepEqual(inventory(retry), original);
|
||||
const allowed = applyAction(reload(retry), 'o', {type: 'passResponse'});
|
||||
assert.deepEqual(allowed.decision, parent);
|
||||
assert.deepEqual(allowed.players[0].hand, []);
|
||||
assert.deepEqual(allowed.players[1].hand, [card]);
|
||||
assert.ok(allowed.players[2].hand.some(c => c.id === second.id));
|
||||
assert.deepEqual(inventory(allowed), original);
|
||||
assert.throws(() => gift(allowed, card.id));
|
||||
});
|
||||
void test('repeated same-phase cancellation costs each physical Karama once and allows the original gift afterward', () => {
|
||||
const g = fixture(); const card = take(g, 'richese-distrans'), first = counter(g), second = counter(g);
|
||||
const inventory = (state: Game) => [...state.deck, ...state.discard, ...state.richeseCache!, ...state.players.flatMap(p => p.hand)].map(c => c.id).sort();
|
||||
const original = inventory(g);
|
||||
let current = g;
|
||||
for (const k of [first, second]) {
|
||||
const pending = gift(reload(current), card.id);
|
||||
assert.equal(pending.response?.kind, 'richeseGift');
|
||||
current = applyAction(reload(pending), 'o', {type: 'card', mode: 'cancel', card: k.id});
|
||||
assert.equal(current.phase, g.phase);
|
||||
assert.deepEqual(current.players[0].hand, [card]);
|
||||
assert.deepEqual(current.players[1].hand, []);
|
||||
assert.equal(current.discard.filter(c => c.id === k.id).length, 1);
|
||||
assert.deepEqual(inventory(current), original);
|
||||
}
|
||||
const transferred = gift(reload(current), card.id);
|
||||
assert.deepEqual(transferred.players[0].hand, []);
|
||||
assert.deepEqual(transferred.players[1].hand, [card]);
|
||||
assert.equal(transferred.discard.filter(c => c.id === first.id || c.id === second.id).length, 2);
|
||||
assert.deepEqual(inventory(transferred), original);
|
||||
});
|
||||
void test('capacity changed during recovery aborts without consuming, discarding or replacing any hand card', () => {
|
||||
const g = fixture(); const card = take(g, 'richese-karama'); counter(g);
|
||||
|
||||
@@ -89,7 +89,21 @@ function settled(state: Game, f: Source, claimed: boolean) {
|
||||
assert.deepEqual(game.phaseOpening, f.originalParent.phaseOpening);
|
||||
assert.deepEqual(game.pendingKarama, f.originalParent.pendingKarama);
|
||||
assert.ok(player(game, f.observer).hand.some(card => card.id === f.parentCounter));
|
||||
reject(game, f.donor, f.giftAction);
|
||||
if (claimed) {
|
||||
// Claiming spent the selected Semuta, so it cannot be offered again.
|
||||
reject(game, f.donor, f.giftAction);
|
||||
} else {
|
||||
// The prevented card is still held and can be re-offered in this phase.
|
||||
// Fork the state so the original parent-continuation checks remain intact.
|
||||
const before = structuredClone(game);
|
||||
const offered = applyAction(game, f.donor, f.giftAction);
|
||||
assert.deepEqual(game, before);
|
||||
assert.equal(offered.response?.kind, 'richeseGift');
|
||||
assert.equal(offered.pendingRicheseGift?.intent.cardId, f.semuta);
|
||||
assert.ok(player(offered, f.donor).hand.some(card => card.id === f.semuta));
|
||||
assert.ok(player(offered, f.observer).hand.some(card => card.id === f.parentCounter));
|
||||
assert.deepEqual(inventory(offered), f.originalInventory);
|
||||
}
|
||||
return game;
|
||||
}
|
||||
|
||||
|
||||
@@ -81,7 +81,12 @@ function prevented(game: Game, f: SemutaRicheseGiftKaramaFixture, claimed: boole
|
||||
assert.deepEqual(done.response, f.originalParent.response); assert.deepEqual(done.decision, f.originalParent.decision);
|
||||
assert.deepEqual(done.phaseOpening, f.originalParent.phaseOpening);
|
||||
assert.deepEqual(done.pendingKarama, f.originalParent.pendingKarama);
|
||||
reject(done, f.donor, f.giftAction);
|
||||
assert.ok(viewGame(done, f.donor).richeseGift?.cards.some(card => card.id === f.gift));
|
||||
const preserved = structuredClone(done);
|
||||
const retry = applyAction(structuredClone(done), f.donor, f.giftAction);
|
||||
assert.equal(retry.response?.kind, 'richeseGift');
|
||||
assert.equal(retry.pendingRicheseGift?.intent.cardId, f.gift);
|
||||
assert.deepEqual(done, preserved);
|
||||
return done;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user