Carry phase bid commitments through expansion auctions
This commit is contained in:
1 parent
b64b732e50
commit
974b7fd99f
10 files changed
+568
-13
No files matched your search
@@ -16,6 +16,17 @@ open mode gates.
|
||||
|
||||
|
||||
## Current checkpoint and work
|
||||
**Basic phase-wide bids through Ixian/Richese preparation — 28 October.
|
||||
Prototyped / Partial.** Existing ordinary-bid questions can survive real Ixian
|
||||
inspection or cancellation and Richese cache-first/last preparation, including
|
||||
native Basic Semuta. Witnesses reserve the respondent's possible cache cost
|
||||
and hand slot without reading rival sealed bids. The ordinary-auction clock now
|
||||
honors a required phase-wide bid. Thirteen related files pass 89 cases,
|
||||
including native Ixian and Richese paths, both answers, funding/slot limits,
|
||||
Truthtrance discard, real clock expiry and legal bot guides. Selected edge
|
||||
fixtures stage conserved unopened cards. Other module combinations, full rules
|
||||
and deployed acceptance remain open. [Scope](TRUTHTRANCE_BID_PROMISES.md#basic-phase-wide-bids-through-ixian-and-richese-preparation--28-october-2026).
|
||||
|
||||
**Basic early Richese cache-bid commitments — 28 October. Prototyped /
|
||||
Partial.** Before Bidding, ask about a minimum bid in the first actual cache
|
||||
lot of the turn. The waiting answer attaches to its real event and uses the
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# Rule decision index
|
||||
|
||||
## Basic phase-wide bids through special-auction preparation — 28 October 2026
|
||||
|
||||
The current-turn Truthtrance FAQ does not define forecasts through an Ixian
|
||||
inspection or Richese cache. The Basic prototype provisionally keeps the
|
||||
ordinary-bid commitment through those real preparations only when a funded,
|
||||
physical ordinary lot remains possible. It reserves the respondent's potential
|
||||
cache purchase without inspecting rival sealed bids; opposing effects can
|
||||
release impossibility. [Scope](TRUTHTRANCE_BID_PROMISES.md#basic-phase-wide-bids-through-ixian-and-richese-preparation--28-october-2026).
|
||||
|
||||
## Early Richese cache-bid commitments — 28 October 2026
|
||||
|
||||
The FAQ permits current-turn action questions but does not specify a future
|
||||
|
||||
@@ -1,5 +1,34 @@
|
||||
# Truthtrance ordinary bid commitments
|
||||
|
||||
## Basic phase-wide bids through Ixian and Richese preparation — 28 October 2026
|
||||
|
||||
**Prototyped / Partial.** A pre-Bidding `{kind:'phaseBid'}` answer can now
|
||||
continue through native Basic Ixian inspection or cancellation and through
|
||||
the Richese cache before ordinary bidding. Richese's cache replaces one
|
||||
ordinary lot; a future witness therefore requires an eligible ordinary lot,
|
||||
an available physical card, funding and hand space. A definite Truthtrance
|
||||
answer spends its actual held card, so a full asker's newly freed hand slot is
|
||||
included. Cache bids do not fulfill or violate an ordinary-bid commitment.
|
||||
|
||||
For a potential cache purchase, the respondent's own bid reserves its physical
|
||||
hand slot, possible Harkonnen bonus slot, funds and committed allied aid; the
|
||||
forecast does not read rival sealed amounts or assume a rival bid. Voluntary
|
||||
actions must preserve a funded Yes, while settled opposing effects can release
|
||||
an impossible one. Existing positive early-cache bid commitments do not admit
|
||||
an independent phase Yes without a joint planner. Basic native Semuta can
|
||||
finish its real discard window; Printed Kull and optional modules remain
|
||||
outside this slice. The publisher FAQ permits current-turn questions while
|
||||
possible; this detailed forecast remains a provisional software policy.
|
||||
|
||||
An expired ordinary-auction clock now uses the existing phase-bid guide when a
|
||||
required Yes prevents passing; a No remains in force until Bidding actually
|
||||
closes. Thirteen focused files pass 89 cases, including native Ixian inspection
|
||||
and cancellation, Richese first/last Once Around and Silent lots, funding and
|
||||
hand-slot limits, actual Truthtrance discard, minimal bot guides and clock
|
||||
expiry. Selected edge cases explicitly stage conserved unopened cards. Types,
|
||||
lint and build pass. Other module combinations, full Truthtrance coverage and
|
||||
deployed acceptance remain open.
|
||||
|
||||
## Basic early Richese cache bid — 28 October 2026
|
||||
|
||||
**Prototyped / Partial.** Before Bidding, a player may ask whether the target
|
||||
@@ -104,8 +133,8 @@ card and post-purchase hand space and funding. Possible Basic and Advanced Harko
|
||||
draws reserve their hand slot; a cancelable Karama-only overbid is not a funded
|
||||
later-lot witness. Existing current-lot answers are considered before forecasting
|
||||
the later invoice. Before Bidding, a witness uses current eligible hand space,
|
||||
funding and available card count, without inventing a future acquisition. Ixian
|
||||
or Richese special-auction preparation is not forecast. Existing transport, revival and aggregate turn commitments retain current-lot
|
||||
funding and available card count, without inventing a future acquisition. The
|
||||
later Basic Ixian/Richese extension is documented above. Existing transport, revival and aggregate turn commitments retain current-lot
|
||||
checks; the future combined planner is not implemented. Before Bidding, an
|
||||
independent bribe can be quoted first using currently funded payments, leaving
|
||||
only the actual remaining funds for the bid; current-lot independent bribe
|
||||
|
||||
+42
-9
@@ -16458,16 +16458,19 @@ function normalBidQuote(g: Game, p: Player, amount: unknown, requestedAid?: unkn
|
||||
function bidOpportunity(g: Game): BidOpportunity | null {
|
||||
const a = g.auction;
|
||||
if (g.status !== 'playing' || g.phase !== 3 || !a || g.currentAuctionSale ||
|
||||
!a.cards[a.index] || !bidPromiseModeSupported(g)) return null;
|
||||
!a.cards[a.index] || (!bidPromiseModeSupported(g) && !phaseBidPromiseModeSupported(g))) return null;
|
||||
return { turn: g.turn, lot: a.index + 1,
|
||||
source: JSON.stringify(['ordinaryBid', g.turn, a.index, a.cards[a.index].id]),
|
||||
bid: a.bid, bidder: a.bidder, passed: a.passed,
|
||||
eligible: g.players.filter(player => player.hand.length < handLimit(player)).map(player => player.id) };
|
||||
}
|
||||
function bidQuestionWindow(g: Game): boolean {
|
||||
function phaseBidCurrentWindow(g: Game): boolean {
|
||||
return !!bidOpportunity(g) && !g.response && !g.decision && !g.phaseOpening && !g.richeseAuction &&
|
||||
!g.ixAuction && !g.pendingKarama && !g.pendingTreacheryDiscard && !g.pendingKull;
|
||||
}
|
||||
function bidQuestionWindow(g: Game): boolean {
|
||||
return bidPromiseModeSupported(g) && phaseBidCurrentWindow(g);
|
||||
}
|
||||
/** A future lot is a witness only after a real funded bid has committed the
|
||||
* current lot to sale. An unbid lot ends ordinary Bidding instead of opening
|
||||
* its remaining cards. The source is kept private in the native auction. */
|
||||
@@ -16478,14 +16481,42 @@ function phaseBidWitnesses(g: Game, p: Player): PhaseBidOpportunity[] {
|
||||
return Math.max(0, uncommittedSpice(g, p) - spent + Math.max(0, (aidFor(g, p)?.amount ?? 0) - spentAid));
|
||||
};
|
||||
if (g.phase < 3) {
|
||||
// No special-auction preparation or future purchase is manufactured.
|
||||
if (byFaction(g, 'ixians') || byFaction(g, 'richese') ||
|
||||
// Ixian inspection returns at most one physical card; if it draws only
|
||||
// one, the actual native continuation places that card in the auction.
|
||||
// Richese's compulsory cache replaces one ordinary lot when available.
|
||||
const cache = !!byFaction(g, 'richese') && (g.richeseCache?.length ?? 0) > 0;
|
||||
// A definite answer spends the actual held Truthtrance before Bidding.
|
||||
// Count its holder's newly freed slot only when that reserved card is
|
||||
// still in a full hand; an unknown answer retains it and binds nothing.
|
||||
const committedTruth = g.truthtrance?.question?.kind === 'phaseBid' ? g.truthtrance.queue[0] : undefined;
|
||||
const holder = committedTruth && g.players.find(seat => seat.id === committedTruth.player);
|
||||
const freedSlot = !!holder && holder.hand.length === handLimit(holder) &&
|
||||
holder.hand.some(card => card.id === committedTruth!.card);
|
||||
if (auctionEligible(g).length + Number(freedSlot) - Number(cache) < 1 ||
|
||||
p.hand.length >= handLimit(p) || !(g.deck.length || g.discard.length)) return [];
|
||||
const max = maximum();
|
||||
return max > 0 ? [{turn:g.turn,player:p.id,kind:'future',minimum:1,maximum:max}] : [];
|
||||
}
|
||||
const lot = bidOpportunity(g), auction = g.auction;
|
||||
if (!lot || !auction) return [];
|
||||
if (!lot || !auction) {
|
||||
// A real first-position cache lot still precedes its frozen positive
|
||||
// ordinary count. Reserve only this respondent's own potentially
|
||||
// winning cache bid and its own pledged aid; never inspect rival sealed
|
||||
// amounts or assume anyone else's future bid.
|
||||
const round = g.richeseBidding, cacheLot = g.richeseAuction;
|
||||
if (!round || round.turn !== g.turn || round.position !== 'first' ||
|
||||
!Number.isSafeInteger(round.normalCount) || round.normalCount! < 1 ||
|
||||
round.stage === 'complete' || !(g.deck.length || g.discard.length)) return [];
|
||||
const ownCache = cacheLot?.source === 'cache' &&
|
||||
!p.hand.some(card => card.id === cacheLot.cardId) &&
|
||||
(cacheLot.outcome?.kind === 'sold' ? cacheLot.outcome.winner === p.id :
|
||||
!cacheLot.outcome && (cacheLot.method === 'silent' ? (cacheLot.sealed[p.id] ?? 0) > 0 : cacheLot.bidder === p.id));
|
||||
const futureCards = ownCache ? 1 + Number(p.faction === 'harkonnen') : 0;
|
||||
if (p.hand.length + futureCards >= handLimit(p)) return [];
|
||||
const spentAid = ownCache ? (g.richeseFunding?.[p.id]?.allyPayment ?? 0) : 0;
|
||||
const max = maximum(0, spentAid);
|
||||
return max > 0 ? [{turn:g.turn,player:p.id,kind:'future',minimum:1,maximum:max}] : [];
|
||||
}
|
||||
const result: PhaseBidOpportunity[] = [];
|
||||
const current = bidPromiseRange(g.bidPromises ?? [], p.id, lot, normalBidMaximum(g, p));
|
||||
if (current) result.push({turn:g.turn,player:p.id,kind:'current',...current});
|
||||
@@ -16785,7 +16816,8 @@ function phaseBidHonorsTurnActions(g: Game, p: Player, lot: BidOpportunity, acti
|
||||
* current-lot commitments. Future bidding with other financial/transport
|
||||
* promises awaits their combined planner; do not invent those resources. */
|
||||
function phaseBidFuturePlannerUnsupported(g: Game, p: Player): boolean {
|
||||
return liveShipmentPromises(g.shipmentPromises ?? [],p.id,g.turn).some(row => row.answer) ||
|
||||
return liveEarlyRicheseBidPromises(g.earlyRicheseBidPromises ?? [],p.id,g.turn).some(row => row.answer) ||
|
||||
liveShipmentPromises(g.shipmentPromises ?? [],p.id,g.turn).some(row => row.answer) ||
|
||||
hasMovementPromises(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) ||
|
||||
@@ -16924,7 +16956,7 @@ function projectedPhaseBidPromises(g: Game, player: string) {
|
||||
}
|
||||
function projectedPhaseBidGuide(g: Game, player: string): {action:Action} | null {
|
||||
const opportunity = bidOpportunity(g), seat = getPlayer(g,player);
|
||||
if (!opportunity || !bidQuestionWindow(g) || g.truthtrance || g.auction?.active !== player) return null;
|
||||
if (!opportunity || !phaseBidCurrentWindow(g) || g.truthtrance || g.auction?.active !== player) return null;
|
||||
const live = livePhaseBidPromises(g.phaseBidPromises ?? [],player,g.turn);
|
||||
if (!live.length) return null;
|
||||
const required = live.some(promise => promise.answer);
|
||||
@@ -17232,8 +17264,9 @@ export function advanceAuctionClock(source: Game, now: number, controlEpoch: str
|
||||
? turnActionCompletion(game, actor)?.bid : undefined;
|
||||
const opportunity = bidOpportunity(game);
|
||||
const required = opportunity && bidRequired(game.bidPromises ?? [], actor, opportunity);
|
||||
if (!whole && !required) throw error;
|
||||
const completion = minimumCompelledAuctionBid(game, actor);
|
||||
const phaseGuide = projectedPhaseBidGuide(game, actor)?.action;
|
||||
if (!whole && !required && phaseGuide?.type !== 'bid') throw error;
|
||||
const completion = phaseGuide?.type === 'bid' ? phaseGuide : minimumCompelledAuctionBid(game, actor);
|
||||
requireRule(completion, 'The expired ordinary bid needs a legal completion of its binding Truthtrance answer.');
|
||||
next = applyAction(game, actor, completion);
|
||||
compelled = true;
|
||||
|
||||
+2
-1
@@ -3701,6 +3701,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Before Basic Bidding, ask about a minimum bid in the first actual Richese cache auction of this turn. The answer waits for the real event and does not predict its card or method. Current uncommitted personal funds and hand space supply a conservative Yes witness; cancellation or an opposing action can release an impossible commitment. The Richese owner’s Once Around turn may vanish if everyone else passes.',
|
||||
'Your waiting cache commitment remains visible until its auction opens or it ends. Once the real lot opens, the ordinary Richese bid guide takes over.',
|
||||
'The phase-wide guide may wait for a later lot only when the existing funded sale, prepared next card and post-sale space and funds support it. An unbid lot, hypothetical rival raise, cancelable unfunded overbid or Harkonnen bonus slot is not invented as a future opportunity. Before Bidding, current funding and available card count provide a provisional forecast; special auction preparation and combined future commitments remain limited.',
|
||||
'In supported Basic Ixian or Richese games, a phase-wide ordinary bid can wait through Ixian inspection or a first-position Richese cache. Only ordinary bids count; a potential cache purchase reserves your funds and hand space. If the ordinary auction clock expires and your Yes forbids passing, the normal guide supplies the required legal bid.',
|
||||
'Yes requires one new accepted bid at or above the minimum while possible. An earlier bid does not count. A pass or lower bid cannot rely on another player raising later. No forbids every later qualifying bid until this lot closes.',
|
||||
'The declaration fulfills Yes even if another player later wins. If you win, the original winning invoice and Karama payment rules still apply. A promise creates no spice or free purchase. Current-highest, already-passed and full-hand players have no new opportunity for this structured question.',
|
||||
'Available answers use current native funding, prepaid allied aid and a spendable Karama. Earlier shipment and movement obligations retain their funding. Unknown future purchases or rival income are not assumed. Several answers must remain jointly possible: Yes sets the required minimum and No can cap a later bid.',
|
||||
@@ -3714,7 +3715,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Separate editors, public previews, target-only answers and persistent event-bound native guides keep winning payments separate.', evidence: ['components/bid-claim-fields.tsx', 'components/bid-promises.tsx', 'components/phase-bid-claim-fields.tsx', 'components/phase-bid-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-phase-bid-controls.test.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'All four existing profiles use the projected feasible answer and ordinary guide. Minimal legal participation only; strategy and calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-bid-runtime.test.ts'] },
|
||||
{ area: 'Documentation', status: 'Implemented', detail: 'General FAQ authority, provisional current-lot meaning, physical declaration/payment distinction and remaining scope are explicit.', evidence: ['docs/TRUTHTRANCE_BID_PROMISES.md', 'docs/RULE_DECISIONS.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Original public H/A/E sources and actual callbacks cover Yes/No, payments, release, multiple original cards, stale ownership and earlier shipment funding. Broader rule/module, recovery and deployed acceptance remain required.', evidence: ['tests/fixture-truth-bid.ts', 'tests/truth-bid-runtime.test.ts', 'tests/truth-bid-controls.test.tsx'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Original public sources and callbacks cover Yes/No, payments, release, Ixian/Richese preparation, clock expiry, multiple original cards and earlier shipment funding. Broader rule/module, recovery and deployed acceptance remain required.', evidence: ['tests/fixture-truth-bid.ts', 'tests/truth-bid-runtime.test.ts', 'tests/truth-bid-controls.test.tsx', 'tests/truth-phase-bid-ix-runtime.test.ts', 'tests/truth-phase-bid-richese-runtime.test.ts', 'tests/truth-phase-bid-richese-limits.test.ts', 'tests/truth-phase-bid-clock.test.ts'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -45,7 +45,11 @@ export function parsePhaseBidClaim(value: unknown): PhaseBidClaim {
|
||||
/** 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 {
|
||||
return !game.kullPreview && bidPromiseModeSupported(game);
|
||||
// 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);
|
||||
}
|
||||
|
||||
export function phaseBidClaimText(claim: PhaseBidClaim): string {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { advanceAuctionClock, applyAction, hasAuctionClockOpportunity, synchronizeAuctionClock,
|
||||
viewGame } from '../game/engine';
|
||||
import { truthBidInventory } from './fixture-truth-bid';
|
||||
import { advanceTruthTurnBid, bindTruthTurnBid, createTruthTurnBidFixture,
|
||||
finishTruthTurnBidLot, purchaseTruthTurnBidLot, truthTurnBidClean,
|
||||
truthTurnBidPlayer } from './fixture-truth-turn-bid';
|
||||
|
||||
const epoch = 'phase-bid-test:0';
|
||||
|
||||
/** All three lots are original cards reached through the actual Basic auction.
|
||||
* Earlier cards are purchased through native bids, never staged into a hand. */
|
||||
function finalLot(answer: 'yes' | 'no') {
|
||||
const fixture = createTruthTurnBidFixture();
|
||||
let game = fixture.game;
|
||||
for (let n = 0; n < 2; n++) game = purchaseTruthTurnBidLot(game, fixture.opponent).game;
|
||||
assert.equal(game.auction?.index, 2);
|
||||
game = advanceTruthTurnBid(game, state => truthTurnBidClean(state) &&
|
||||
state.auction?.active === fixture.target);
|
||||
game = bindTruthTurnBid(fixture, answer, 4, game).game;
|
||||
assert.equal(game.auction?.active, fixture.target);
|
||||
assert.equal(game.auction?.clockVersion, 1);
|
||||
assert.ok(hasAuctionClockOpportunity(game));
|
||||
return { fixture, game, card: game.auction!.cards[game.auction!.index].id };
|
||||
}
|
||||
|
||||
void test('a real expiring last-lot clock submits the required phase Yes bid and keeps native payment', () => {
|
||||
const { fixture: f, game, card } = finalLot('yes');
|
||||
const original = structuredClone(game);
|
||||
assert.throws(() => applyAction(game, f.target, { type: 'passBid' }));
|
||||
assert.deepEqual(game, original);
|
||||
const guide = viewGame(game, f.target).phaseBidPromiseGuide;
|
||||
assert.ok(guide); assert.equal(guide.action.type, 'bid'); assert.equal(guide.action.amount, 4);
|
||||
const armed = synchronizeAuctionClock(game, 1_000, epoch);
|
||||
assert.equal(advanceAuctionClock(armed, 10_999, epoch), armed);
|
||||
const expired = advanceAuctionClock(armed, 11_000, epoch);
|
||||
assert.equal(expired.auction?.bidder, f.target);
|
||||
assert.equal(expired.auction?.bid, 4);
|
||||
assert.equal(truthTurnBidPlayer(expired, f.target).spice, truthTurnBidPlayer(game, f.target).spice,
|
||||
'A deadline declaration does not prepay or waive the native invoice');
|
||||
assert.ok(expired.phaseBidPromises?.some(row => row.player === f.target && row.minimum === 4 && row.fulfilled));
|
||||
assert.match(expired.log.at(-1)!.text, /legal bid required by their binding Truthtrance answer was submitted/);
|
||||
assert.deepEqual(truthBidInventory(expired), f.originalCards);
|
||||
const settled = finishTruthTurnBidLot(expired);
|
||||
assert.equal(truthTurnBidPlayer(settled, f.target).spice, truthTurnBidPlayer(game, f.target).spice - 4);
|
||||
assert.ok(truthTurnBidPlayer(settled, f.target).hand.some(item => item.id === card));
|
||||
assert.deepEqual(truthBidInventory(settled), f.originalCards);
|
||||
});
|
||||
|
||||
void test('a real expiring last-lot clock preserves phase No and takes the legal pass', () => {
|
||||
const { fixture: f, game } = finalLot('no');
|
||||
assert.throws(() => applyAction(game, f.target, { type: 'bid', amount: 4, allyPayment: 0 }));
|
||||
const expected = applyAction(game, f.target, { type: 'passBid' });
|
||||
const armed = synchronizeAuctionClock(game, 1_000, epoch);
|
||||
const expired = advanceAuctionClock(armed, 11_000, epoch);
|
||||
assert.equal(expired.phase, expected.phase);
|
||||
assert.deepEqual(expired.auction, expected.auction);
|
||||
assert.deepEqual(expired.phaseBidPromises, expected.phaseBidPromises);
|
||||
assert.ok(expired.phaseBidPromises?.some(row => row.player === f.target && !row.answer && !row.fulfilled && !row.released),
|
||||
'A phase-wide No stays live while other bidders can still act');
|
||||
assert.match(expired.log.at(-1)!.text, /ten-second bidding time expired; they passed/);
|
||||
assert.deepEqual(truthBidInventory(expired), f.originalCards);
|
||||
const closed = advanceTruthTurnBid(expired, state => state.phase !== 3);
|
||||
assert.ok(closed.phaseBidPromises?.some(row => row.player === f.target && !row.answer && row.fulfilled),
|
||||
'The No is fulfilled only after the actual Bidding phase closes');
|
||||
assert.deepEqual(truthBidInventory(closed), f.originalCards);
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import {
|
||||
actTruthMovementNativeEarly as act,
|
||||
advanceTruthMovementNativeEarly as advance,
|
||||
createTruthMovementNativeEarlyFixture as fixture,
|
||||
truthMovementNativeEarlyClean as clean,
|
||||
truthMovementNativeEarlyInventory as inventory,
|
||||
truthMovementNativeEarlyPlayer as player,
|
||||
} from './fixture-truth-movement-native-early';
|
||||
|
||||
/** Native Basic setup uses original unopened deal and Spice positions; the
|
||||
* shared fixture reaches clean Charity without a staged hand or promise. */
|
||||
function bind(answer: 'yes' | 'no', withCancel = false) {
|
||||
const f = fixture({ roster: 'ixians-tleilaxu' });
|
||||
let game = structuredClone(f.game);
|
||||
let karama: string | undefined;
|
||||
if (withCancel) {
|
||||
// A controlled physical supplement moves one still-unopened Karama to the
|
||||
// Tleilaxu hand. This is only the optional cancellation cost: no wallet,
|
||||
// response, auction, bidder, pool or promise is installed.
|
||||
const index = game.deck.findIndex(card => card.effect === 'karama');
|
||||
assert.ok(index >= 0);
|
||||
const [card] = game.deck.splice(index, 1);
|
||||
karama = card.id;
|
||||
player(game, 'tleilaxu').hand.push(card);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
}
|
||||
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' });
|
||||
}
|
||||
assert.equal(game.truthtrance?.stage, 'ask');
|
||||
game = act(game, f.asker, { type: 'truthAsk', question: {
|
||||
kind: 'phaseBid', target: f.target, turn: game.turn, minimum: 4,
|
||||
} });
|
||||
assert.ok(viewGame(game, f.target).truthPhaseBidAnswers?.includes(answer));
|
||||
assert.equal(viewGame(game, f.asker).truthPhaseBidAnswers, null);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const target = viewGame(game, f.target);
|
||||
target.players.find(seat => seat.id === f.target)!.bot = difficulty;
|
||||
const action = botActions(target)[0];
|
||||
assert.equal(action?.type, 'truthAnswer');
|
||||
assert.ok(target.truthPhaseBidAnswers!.includes(action.answer as 'yes' | 'no' | 'unknown'));
|
||||
}
|
||||
game = act(game, f.target, { type: 'truthAnswer', answer });
|
||||
assert.equal(game.truthtrance, null);
|
||||
assert.ok(game.phaseBidPromises?.some(row => row.player === f.target && row.answer === (answer === 'yes')));
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
return { f, game, karama };
|
||||
}
|
||||
|
||||
function ordinaryLot(source: Game, karama?: string) {
|
||||
let game: Game;
|
||||
if (karama) {
|
||||
game = advance(source, state => state.response?.kind === 'ixAuction' && !!state.ixAuction);
|
||||
assert.deepEqual(game.ixAuction!.cards, [], 'The real response precedes drawing the Ixian pool');
|
||||
game = act(game, 'tleilaxu', { type: 'card', card: karama, mode: 'cancel' });
|
||||
} else {
|
||||
// With no eligible counter the native Ixian response auto-allows, so it
|
||||
// can proceed directly to the real inspection decision in one action.
|
||||
game = advance(source, state => state.decision?.kind === 'ixAuction' && !state.response);
|
||||
const pool = game.ixAuction!.cards;
|
||||
assert.ok(pool.length > 1);
|
||||
const returned = pool[0].id, retained = pool.slice(1).map(card => card.id).sort();
|
||||
game = act(game, game.decision!.player, { type: 'decision', card: returned, position: 'bottom' });
|
||||
game = advance(game, state => clean(state) && !!state.auction);
|
||||
assert.deepEqual(game.ixAuctionKnown!.cards.map(card => card.id).sort(), retained);
|
||||
assert.ok(game.deck.some(card => card.id === returned));
|
||||
return game;
|
||||
}
|
||||
game = advance(game, state => clean(state) && !!state.auction);
|
||||
assert.equal(game.ixAuction ?? null, null);
|
||||
assert.equal(game.ixAuctionKnown ?? null, null, 'A canceled inspection grants no Ixian private pool');
|
||||
assert.equal(game.discard.filter(card => card.id === karama).length, 1);
|
||||
return game;
|
||||
}
|
||||
|
||||
function toTarget(game: Game, target: string) {
|
||||
for (let n = 0; n < 20; n++) {
|
||||
if (clean(game) && game.auction?.active === target) return game;
|
||||
if (clean(game) && game.auction) game = act(game, game.auction.active, { type: 'passBid' });
|
||||
else game = advance(game, clean);
|
||||
}
|
||||
throw new Error('The original ordinary lot did not reach the promised bidder');
|
||||
}
|
||||
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => act(game, actor, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
|
||||
for (const canceled of [false, true]) {
|
||||
const source = canceled ? 'canceled Ixian inspection' : 'actual Ixian pool selection';
|
||||
void test(`Basic early phase-bid Yes survives ${source} and completes through the real ordinary bid`, () => {
|
||||
const { f, game: bound, karama } = bind('yes', canceled);
|
||||
let game = toTarget(ordinaryLot(bound, karama), f.target);
|
||||
const outstanding = viewGame(game, f.target).phaseBidPromises.find(row => row.minimum === 4);
|
||||
assert.ok(outstanding && !outstanding.fulfilled && !outstanding.released);
|
||||
reject(game, f.target, { type: 'passBid' });
|
||||
// A smaller bid may remain legal when an actual funded sale opens a later
|
||||
// lot; the phase promise does not force the current lot's threshold.
|
||||
const target = viewGame(game, f.target), guide = target.phaseBidPromiseGuide;
|
||||
assert.ok(guide); assert.equal(guide.action.type, 'bid');
|
||||
assert.equal(guide.action.amount, 4);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const bot = viewGame(game, f.target);
|
||||
bot.players.find(seat => seat.id === f.target)!.bot = difficulty;
|
||||
const action = botActions(bot)[0]; assert.ok(action);
|
||||
const trial = act(game, f.target, action);
|
||||
assert.ok(trial.phaseBidPromises?.some(row => row.player === f.target && row.fulfilled));
|
||||
}
|
||||
game = act(game, f.target, guide.action);
|
||||
assert.ok(game.phaseBidPromises?.some(row => row.player === f.target && row.fulfilled));
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test(`Basic early phase-bid No survives ${source} and constrains the real ordinary lot`, () => {
|
||||
const { f, game: bound, karama } = bind('no', canceled);
|
||||
let game = toTarget(ordinaryLot(bound, karama), f.target);
|
||||
reject(game, f.target, { type: 'bid', amount: 4, allyPayment: 0 });
|
||||
game = act(game, f.target, { type: 'passBid' });
|
||||
game = advance(game, state => state.phase > 3 && clean(state));
|
||||
assert.ok(game.phaseBidPromises?.some(row => row.player === f.target && !row.answer && row.fulfilled));
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
|
||||
import {
|
||||
actTruthtranceRichesePromises as act,
|
||||
advanceTruthtranceRichesePromises as advance,
|
||||
askTruthtranceRichesePromises as ask,
|
||||
createTruthtranceRichesePromisesFixture as fixture,
|
||||
passTruthRicheseFreshDiscard,
|
||||
truthtranceRichesePromisesClean as clean,
|
||||
truthtranceRichesePromisesInventory as inventory,
|
||||
truthtranceRichesePromisesPlayer as player,
|
||||
} from './fixture-truthtrance-richese-promises';
|
||||
|
||||
type Fixture = ReturnType<typeof fixture>;
|
||||
type Method = 'onceAround' | 'silent';
|
||||
|
||||
/** Supplemental setup moves real unopened cards only, retaining all physical
|
||||
* card identities; no funds, promise, auction result or board state is staged. */
|
||||
function fillHand(game: Game, f: Fixture, actor: string, size: number) {
|
||||
while (player(game, actor).hand.length < size) {
|
||||
const index = game.deck.findIndex(card => card.id !== f.ordinaryLot);
|
||||
assert.ok(index >= 0);
|
||||
player(game, actor).hand.push(game.deck.splice(index, 1)[0]);
|
||||
}
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
}
|
||||
|
||||
function bind(f: Fixture, source: Game) {
|
||||
const asked = ask(source, f, { kind: 'phaseBid', target: f.emperor, turn: source.turn, minimum: 4 });
|
||||
assert.ok(viewGame(asked, f.emperor).truthPhaseBidAnswers?.includes('yes'));
|
||||
const bound = passTruthRicheseFreshDiscard(act(asked, f.emperor, { type: 'truthAnswer', answer: 'yes' }));
|
||||
assert.ok(bound.phaseBidPromises?.some(row => row.player === f.emperor && row.answer && !row.released));
|
||||
return bound;
|
||||
}
|
||||
|
||||
function cacheTurn(game: Game, target: string, method: Method) {
|
||||
const options = { position: 'first' as const, method };
|
||||
game = advance(game, state => clean(state) && state.richeseAuction?.source === 'cache' &&
|
||||
state.richeseAuction.method === method && !state.richeseAuction.outcome, [], options);
|
||||
if (method === 'onceAround') game = advance(game, state => clean(state) &&
|
||||
state.richeseAuction?.active === target, [], options);
|
||||
return game;
|
||||
}
|
||||
|
||||
function rejects(source: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(source);
|
||||
assert.throws(() => act(source, actor, action));
|
||||
assert.deepEqual(source, before, 'A rejected voluntary bid must not mutate its source');
|
||||
}
|
||||
|
||||
for (const method of ['onceAround', 'silent'] as const) {
|
||||
void test(`Basic ${method} cache bid cannot spend the last funds for a phase-wide Yes`, () => {
|
||||
const f = fixture({ advanced: false });
|
||||
const bound = bind(f, structuredClone(f.earlySource));
|
||||
assert.equal(player(bound, f.emperor).spice, 10);
|
||||
const current = cacheTurn(bound, f.emperor, method);
|
||||
const event = current.richeseAuction!.event;
|
||||
rejects(current, f.emperor, { type: 'richeseBid', event, amount: 7, allyPayment: 0 });
|
||||
const accepted = act(current, f.emperor, { type: 'richeseBid', event, amount: 6, allyPayment: 0 });
|
||||
assert.ok(accepted.phaseBidPromises?.some(row => row.player === f.emperor && row.answer &&
|
||||
!row.fulfilled && !row.released));
|
||||
assert.deepEqual(inventory(accepted), f.originalCards);
|
||||
});
|
||||
|
||||
void test(`Basic ${method} cache bid cannot consume the only hand slot needed for a phase-wide Yes`, () => {
|
||||
const f = fixture({ advanced: false });
|
||||
const source = structuredClone(f.earlySource);
|
||||
fillHand(source, f, f.emperor, 3);
|
||||
const bound = bind(f, source);
|
||||
const current = cacheTurn(bound, f.emperor, method);
|
||||
const event = current.richeseAuction!.event;
|
||||
rejects(current, f.emperor, { type: 'richeseBid', event, amount: 1, allyPayment: 0 });
|
||||
const passed = act(current, f.emperor, { type: 'richeseBid', event,
|
||||
amount: method === 'silent' ? 0 : null });
|
||||
assert.ok(passed.phaseBidPromises?.some(row => row.player === f.emperor && row.answer &&
|
||||
!row.fulfilled && !row.released));
|
||||
assert.deepEqual(inventory(passed), f.originalCards);
|
||||
});
|
||||
}
|
||||
|
||||
void test('a full Truthtrance holder creates a real ordinary slot by discarding the answered card', () => {
|
||||
const f = fixture({ advanced: false });
|
||||
const source = structuredClone(f.earlySource);
|
||||
// Immediately before answering only Emperor has an open slot. The definite
|
||||
// Truthtrance answer consumes Atreides' real held card, so Atreides also has
|
||||
// an open slot before the compulsory cache reduces the ordinary lot count.
|
||||
fillHand(source, f, f.asker, 4);
|
||||
fillHand(source, f, f.richese, 4);
|
||||
assert.equal(player(source, f.emperor).hand.length, 1);
|
||||
const bound = bind(f, source);
|
||||
assert.equal(player(bound, f.asker).hand.length, 3);
|
||||
assert.equal(player(bound, f.richese).hand.length, 4);
|
||||
const current = advance(bound, state => clean(state) && !!state.auction &&
|
||||
!state.richeseAuction && state.auction.active === f.emperor, [], { position: 'first' });
|
||||
assert.equal(current.richeseBidding?.normalCountBeforeCache, 2);
|
||||
assert.equal(current.richeseBidding?.normalCount, 1);
|
||||
const guide = viewGame(current, f.emperor).phaseBidPromiseGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.amount, 4);
|
||||
const fulfilled = act(current, f.emperor, guide.action);
|
||||
assert.ok(fulfilled.phaseBidPromises?.some(row => row.player === f.emperor && row.fulfilled));
|
||||
assert.deepEqual(inventory(fulfilled), f.originalCards);
|
||||
});
|
||||
|
||||
void test('an externally reduced one-eligible-seat state cannot retain a speculative ordinary Yes', () => {
|
||||
const f = fixture({ advanced: false });
|
||||
const bound = bind(f, structuredClone(f.earlySource));
|
||||
const staged = structuredClone(bound);
|
||||
// Controlled external capacity loss after the answered Truthtrance: move
|
||||
// unopened cards into the other two hands, leaving only Emperor eligible.
|
||||
fillHand(staged, f, f.asker, 4);
|
||||
fillHand(staged, f, f.richese, 4);
|
||||
assert.equal(player(staged, f.emperor).hand.length, 1);
|
||||
const settled = normalizeAutomaticGame(staged);
|
||||
assert.ok(settled.phaseBidPromises?.some(row => row.player === f.emperor && row.answer && row.released),
|
||||
'One eligible seat cannot guarantee an ordinary lot while the cache consumes that opportunity');
|
||||
assert.deepEqual(inventory(settled), f.originalCards);
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import {
|
||||
actTruthtranceRichesePromises as act,
|
||||
advanceTruthtranceRichesePromises as advance,
|
||||
askTruthtranceRichesePromises as ask,
|
||||
createTruthtranceRichesePromisesFixture as fixture,
|
||||
passTruthRicheseFreshDiscard,
|
||||
truthtranceRichesePromisesClean as clean,
|
||||
truthtranceRichesePromisesInventory as inventory,
|
||||
truthtranceRichesePromisesPlayer as player,
|
||||
} from './fixture-truthtrance-richese-promises';
|
||||
|
||||
/** Real Basic Richese/Atreides/Emperor setup and Charity. The fixture orders
|
||||
* unopened original cards and Spice cards only: no hands, funds, cache,
|
||||
* commitments or phase state are staged. */
|
||||
function bind(answer: 'yes' | 'no') {
|
||||
const f = fixture({ advanced: false });
|
||||
const question = { kind: 'phaseBid' as const, target: f.emperor,
|
||||
turn: f.earlySource.turn, minimum: 4 };
|
||||
const asked = ask(structuredClone(f.earlySource), f, question);
|
||||
assert.ok(viewGame(asked, f.emperor).truthPhaseBidAnswers?.includes(answer));
|
||||
const answered = act(asked, f.emperor, { type: 'truthAnswer', answer });
|
||||
const bound = passTruthRicheseFreshDiscard(answered);
|
||||
assert.equal(bound.truthtrance, null);
|
||||
assert.equal(bound.discard.filter(card => card.id === f.truth).length, 1);
|
||||
assert.ok(bound.phaseBidPromises?.some(row => row.player === f.emperor &&
|
||||
row.minimum === 4 && row.answer === (answer === 'yes')));
|
||||
assert.deepEqual(inventory(bound), f.originalCards);
|
||||
return { f, asked, bound };
|
||||
}
|
||||
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => act(game, actor, action));
|
||||
assert.deepEqual(game, before, 'A rejected declaration must not mutate the game');
|
||||
}
|
||||
|
||||
type Method = 'onceAround' | 'silent';
|
||||
function cacheBidFirst(game: Game, actor: string, method: Method) {
|
||||
const options = { position: 'first' as const, method };
|
||||
game = advance(game, state => clean(state) && !!state.richeseAuction &&
|
||||
state.richeseAuction.source === 'cache' && !state.richeseAuction.outcome, [], options);
|
||||
if (method === 'onceAround') game = advance(game, state => clean(state) &&
|
||||
state.richeseAuction?.active === actor, [], options);
|
||||
const event = game.richeseAuction!.event;
|
||||
// This actual special-auction bid is not a phase-wide ordinary bid.
|
||||
game = act(game, actor, { type: 'richeseBid', event, amount: 4, allyPayment: 0 });
|
||||
const row = game.phaseBidPromises!.find(p => p.player === actor && p.minimum === 4)!;
|
||||
assert.ok(!row.fulfilled && !row.released,
|
||||
'A cache bid neither fulfills nor contradicts the ordinary-bid question');
|
||||
return game;
|
||||
}
|
||||
|
||||
function ordinaryTurn(game: Game, actor: string, position: 'first' | 'last', method: Method) {
|
||||
return advance(game, state => clean(state) && !!state.auction &&
|
||||
!state.richeseAuction && state.auction.active === actor, [], { position, method });
|
||||
}
|
||||
|
||||
for (const position of ['first', 'last'] as const) for (const method of ['onceAround', 'silent'] as const) {
|
||||
for (const answer of ['yes', 'no'] as const) {
|
||||
void test(`Basic phase bid ${answer} survives ${position} ${method} Richese cache and governs a real ordinary bid`, () => {
|
||||
const { f, bound } = bind(answer);
|
||||
const beforeOrdinary = position === 'first' ? cacheBidFirst(bound, f.emperor, method) : bound;
|
||||
const current = ordinaryTurn(beforeOrdinary, f.emperor, position, method);
|
||||
const promise = current.phaseBidPromises!.find(row => row.player === f.emperor && row.minimum === 4)!;
|
||||
assert.ok(promise && !promise.fulfilled && !promise.released);
|
||||
if (position === 'first') {
|
||||
assert.ok(player(current, f.emperor).hand.some(card => card.id === f.cacheCard));
|
||||
assert.equal(player(current, f.emperor).spice, 6);
|
||||
}
|
||||
let next: Game;
|
||||
if (answer === 'yes') {
|
||||
const guide = viewGame(current, f.emperor).phaseBidPromiseGuide;
|
||||
assert.ok(guide); assert.equal(guide.action.type, 'bid');
|
||||
assert.equal(guide.action.amount, 4);
|
||||
next = act(current, f.emperor, guide.action);
|
||||
assert.ok(next.phaseBidPromises!.some(row => row.player === f.emperor && row.fulfilled));
|
||||
} else {
|
||||
reject(current, f.emperor, { type: 'bid', amount: 4, allyPayment: 0 });
|
||||
next = act(current, f.emperor, { type: 'bid', amount: 3, allyPayment: 0 });
|
||||
assert.ok(next.phaseBidPromises!.some(row => row.player === f.emperor &&
|
||||
!row.answer && !row.fulfilled && !row.released));
|
||||
}
|
||||
assert.deepEqual(inventory(next), f.originalCards);
|
||||
if (position === 'last') {
|
||||
// The ordinary winner's invoice and any remaining auction controls
|
||||
// resolve before the original cache offer; do not invent another lot.
|
||||
const later = advance(next, state => clean(state) && !!state.richeseAuction &&
|
||||
state.richeseAuction.source === 'cache' && !state.richeseAuction.outcome,
|
||||
[], { position, method });
|
||||
assert.equal(later.richeseAuction!.method, method);
|
||||
assert.deepEqual(inventory(later), f.originalCards);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void test('all four profiles answer and follow a phase-bid guide after a native Richese cache auction', () => {
|
||||
const { f, asked } = bind('yes');
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const answerView = viewGame(asked, f.emperor);
|
||||
answerView.players.find(row => row.id === f.emperor)!.bot = difficulty;
|
||||
const answer = botActions(answerView)[0];
|
||||
assert.equal(answer?.type, 'truthAnswer');
|
||||
assert.ok(answer.answer === 'yes' || answer.answer === 'no');
|
||||
const bound = passTruthRicheseFreshDiscard(act(structuredClone(asked), f.emperor, answer));
|
||||
const current = ordinaryTurn(bound, f.emperor, 'first', 'silent');
|
||||
const view = viewGame(current, f.emperor);
|
||||
view.players.find(row => row.id === f.emperor)!.bot = difficulty;
|
||||
const guide = view.phaseBidPromiseGuide;
|
||||
assert.ok(guide, 'The active seat has an actual legal phase-bid guide');
|
||||
const action = botActions(view)[0];
|
||||
assert.deepEqual(action, guide.action);
|
||||
const done = act(current, f.emperor, action);
|
||||
assert.deepEqual(inventory(done), f.originalCards);
|
||||
}
|
||||
});
|
||||
|
||||
void test('an externally canceled cache preserves a pre-Bidding ordinary phase Yes', () => {
|
||||
const f = fixture({ advanced: false });
|
||||
const source = structuredClone(f.earlySource);
|
||||
// Controlled physical supplement: move one actual unopened Karama into
|
||||
// Atreides' hand for the real cache-cancellation response. All cards remain.
|
||||
const index = source.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0);
|
||||
const card = source.deck.splice(index, 1)[0];
|
||||
player(source, f.asker).hand.push(card);
|
||||
assert.deepEqual(inventory(source), f.originalCards);
|
||||
const asked = ask(source, f, { kind: 'phaseBid', target: f.emperor, turn: source.turn, minimum: 4 });
|
||||
assert.ok(viewGame(asked, f.emperor).truthPhaseBidAnswers?.includes('yes'));
|
||||
const bound = passTruthRicheseFreshDiscard(act(asked, f.emperor, { type: 'truthAnswer', answer: 'yes' }));
|
||||
const declaration = advance(bound, state => state.decision?.kind === 'richeseDeclaration');
|
||||
const opened = act(declaration, f.richese, { type: 'decision',
|
||||
event: declaration.richeseBidding!.event, position: 'first' });
|
||||
assert.equal(opened.response?.kind, 'richeseAuction');
|
||||
const canceled = passTruthRicheseFreshDiscard(act(opened, f.asker, { type: 'card', card: card.id, mode: 'cancel' }));
|
||||
assert.equal(canceled.richeseBidding?.cacheCanceled, true);
|
||||
assert.ok(canceled.phaseBidPromises?.some(row => row.player === f.emperor &&
|
||||
row.answer && !row.fulfilled && !row.released));
|
||||
const current = ordinaryTurn(canceled, f.emperor, 'first', 'onceAround');
|
||||
const guide = viewGame(current, f.emperor).phaseBidPromiseGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.amount, 4);
|
||||
const done = act(current, f.emperor, guide.action);
|
||||
assert.ok(done.phaseBidPromises?.some(row => row.player === f.emperor && row.fulfilled));
|
||||
assert.deepEqual(inventory(done), f.originalCards);
|
||||
});
|
||||
Reference in new issue
Block a user