Add cumulative Truthtrance force-revival commitments
This commit is contained in:
1 parent
bd6e5bbf7f
commit
24653799cf
21 files changed
+1540
-56
No files matched your search
@@ -24,6 +24,7 @@ import {
|
||||
liveShipmentPromises,
|
||||
} from '@/game/shipment-promises';
|
||||
import { ShipmentPromises } from './shipment-promises';
|
||||
import { RevivalPromises } from './revival-promises';
|
||||
import { BidPromises } from './bid-promises';
|
||||
import { HomeworldShipment } from './homeworld-shipment';
|
||||
import { HomeworldRevivalDeployment } from './homeworld-revival-deployment';
|
||||
@@ -2031,6 +2032,7 @@ export function GameTable({
|
||||
{g.status === 'playing' && g.phase < 5 && (
|
||||
<ShipmentPromises game={g} act={act} busy={busy} />
|
||||
)}
|
||||
{g.status === 'playing' && <RevivalPromises game={g} send={act} busy={busy} />}
|
||||
{g.status === 'playing' && <BidPromises game={g} send={act} busy={busy} />}
|
||||
{g.guildBetrayalReaction ? (
|
||||
<NexusGuildBetrayal game={g} act={act} busy={reactionBusy} />
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import { parseRevivalClaim, type RevivalClaim } from '@/game/revival-promises';
|
||||
import { Input } from './ui/input';
|
||||
|
||||
export function revivalClaimInputError(value: unknown): string | null {
|
||||
try { parseRevivalClaim(value); return null; }
|
||||
catch (error) { return error instanceof Error ? error.message : 'Choose a whole physical-force minimum from 1 to 20.'; }
|
||||
}
|
||||
|
||||
/** The parent fixes this turn; the editor chooses physical counters and a
|
||||
* cumulative minimum without reconstructing native revival feasibility. */
|
||||
export function RevivalClaimFields(props: {
|
||||
value: RevivalClaim;
|
||||
onChange: (claim: RevivalClaim) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const error = revivalClaimInputError(props.value);
|
||||
const change = (value: unknown) => {
|
||||
if (!props.disabled && !revivalClaimInputError(value)) props.onChange(parseRevivalClaim(value));
|
||||
};
|
||||
return <fieldset className="grid gap-2" disabled={props.disabled} aria-label="Ordinary native Revival condition">
|
||||
<legend>Future ordinary Revival returns</legend>
|
||||
<p>Turn {props.value.turn}</p>
|
||||
<label htmlFor="truth-revival-counter">Physical force counters</label>
|
||||
<select id="truth-revival-counter" value={props.value.counter} disabled={props.disabled}
|
||||
onChange={event => change({ ...props.value, counter: event.currentTarget.value })}>
|
||||
<option value="total">All physical forces</option>
|
||||
<option value="normal">Normal forces</option>
|
||||
<option value="elite">Elite forces</option>
|
||||
</select>
|
||||
<label htmlFor="truth-revival-minimum">Minimum future ordinary returns</label>
|
||||
<Input id="truth-revival-minimum" type="number" min={1} max={20} step={1} required disabled={props.disabled}
|
||||
value={Number.isFinite(props.value.minimum) ? props.value.minimum : ''} aria-invalid={!!error}
|
||||
aria-describedby="truth-revival-help"
|
||||
onChange={event => change({ ...props.value, minimum: event.currentTarget.valueAsNumber })} />
|
||||
{error && <p role="alert">{error}</p>}
|
||||
<p className="fine" id="truth-revival-help">
|
||||
Count cumulative future ordinary native returns after the answer. Total counts normal and elite counters together;
|
||||
each physical counter counts once. Earlier returns, card effects, Ghola and Emperor extra revivals do not count.
|
||||
</p>
|
||||
</fieldset>;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { liveRevivalPromises, revivalClaimText, revivalPromiseModeSupported } from '@/game/revival-promises';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
type RevivalPromiseProps = { game: GameView; busy: boolean; send: (action: Action) => void };
|
||||
|
||||
/** Preserve the server's exact native return/Ready quote. The client checks
|
||||
* only the live personalized event and current owned action opportunity. */
|
||||
export function RevivalPromiseGuideButton(props: RevivalPromiseProps) {
|
||||
const game = props.game, guide = game.revivalPromiseGuide;
|
||||
if (!guide) return null;
|
||||
const actor = game.me, turn = game.turn, order = game.revivalOrder;
|
||||
const action = guide.action, event = action.revivalPromiseEvent, signature = JSON.stringify(action);
|
||||
const canAct = () => {
|
||||
const current = props.game, me = current.players.find(player => player.id === current.me);
|
||||
return !props.busy && current === game && revivalPromiseModeSupported(current) &&
|
||||
current.status === 'playing' && current.phase === 4 && current.me === actor && current.turn === turn &&
|
||||
!current.ready.includes(actor) && current.revivalOrder === order &&
|
||||
(!current.revivalOrder || current.revivalOrder.turn === turn && current.revivalOrder.remaining[0] === actor && current.active === actor) &&
|
||||
current.revivalPromiseGuide === guide && current.revivalPromiseGuide.action === action && JSON.stringify(action) === signature &&
|
||||
typeof event === 'string' && (action.type === 'revive' || action.type === 'ready') &&
|
||||
liveRevivalPromises(current.revivalPromises, actor, turn).some(promise => promise.event === event) && !!me && !me.autopilot &&
|
||||
!current.truthtrance && !current.response && !current.decision && !current.phaseOpening &&
|
||||
!current.automaticContinuationPending && !current.roomControl?.paused && !current.roomControl?.closed;
|
||||
};
|
||||
return <Button className="game-action min-h-11 whitespace-normal" data-testid="revival-promise-guide"
|
||||
disabled={!canAct()} onClick={() => { if (canAct()) props.send(action); }}>
|
||||
{action.type === 'revive'
|
||||
? `Revive ${typeof action.amount === 'number' ? action.amount : 'the suggested number of'} forces while honoring your answers`
|
||||
: 'Finish Revival while honoring your answers'}
|
||||
</Button>;
|
||||
}
|
||||
|
||||
export function RevivalPromises(props: RevivalPromiseProps) {
|
||||
const own = props.game.revivalPromises.filter(promise => promise.player === props.game.me);
|
||||
if (!own.length) return null;
|
||||
const live = liveRevivalPromises(own, props.game.me, props.game.turn).length > 0;
|
||||
const other = props.game.revivalOrder?.remaining[0];
|
||||
const waiting = live && (props.game.phase < 4 || props.game.phase === 4 && other && other !== props.game.me);
|
||||
return <section className="notice space-y-3" aria-label="Your Truthtrance Revival promises" id="revival-promise-guidance">
|
||||
<h3>Your Revival promises</h3>
|
||||
<ul>{own.map(promise => <li key={promise.event}>
|
||||
Turn {promise.turn}: You answered {promise.answer ? 'Yes' : 'No'} to {revivalClaimText(promise)}.
|
||||
{' '}Recorded future ordinary returns: {promise.progress} {promise.counter === 'total' ? 'physical' : promise.counter} force{promise.progress === 1 ? '' : 's'}.
|
||||
{promise.released ? ' Released: this commitment is no longer possible.'
|
||||
: promise.fulfilled ? ' Fulfilled: this commitment is complete.' : ' This answer is binding for this turn.'}
|
||||
</li>)}</ul>
|
||||
<p>
|
||||
Yes requires reaching the cumulative minimum through future ordinary native returns while it remains possible.
|
||||
No keeps your cumulative future returns below that minimum, including across smaller split requests.
|
||||
Prior returns, card effects, Ghola and Emperor extra revivals do not count.
|
||||
</p>
|
||||
<p className="fine">
|
||||
Each normal or elite force counter counts once; total includes both types.
|
||||
Original revival costs, limits and turn order still apply. Optional modules remain outside this commitment.
|
||||
{' '}<a href="/rules#truthtrance-revival">Revival promise rules</a>.
|
||||
</p>
|
||||
{waiting && <p>Your Revival opportunity is still ahead; this promise remains visible while the current actor finishes.</p>}
|
||||
<RevivalPromiseGuideButton {...props} />
|
||||
</section>;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client';
|
||||
import { MovementClaimFields } from './movement-claim-fields';
|
||||
import { RevivalClaimFields, revivalClaimInputError } from './revival-claim-fields';
|
||||
import type { RevivalClaim } from '@/game/revival-promises';
|
||||
import { BidClaimFields, bidClaimInputError } from './bid-claim-fields';
|
||||
import type { BidClaim } from '@/game/bid-promises';
|
||||
import { TraitorDecisionAnswers } from './traitor-decision-promises';
|
||||
@@ -55,8 +57,10 @@ export function Truthtrance({
|
||||
g.players.find((p) => p.id !== me.id)!.id,
|
||||
);
|
||||
const [kind, setKind] = useState<
|
||||
'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid'
|
||||
'fact' | 'freeform' | 'battlePlan' | 'shipment' | 'mixedShipment' | 'movement' | 'traitorCall' | 'bid' | 'revival'
|
||||
>('fact');
|
||||
const [revivalSelection, setRevivalSelection] = useState<Pick<RevivalClaim, 'counter' | 'minimum'>>({ counter: 'total', minimum: 1 });
|
||||
const revivalClaim: RevivalClaim = { turn: g.turn, ...revivalSelection };
|
||||
const [bidMinimum, setBidMinimum] = useState(1);
|
||||
const bidClaim: BidClaim = { turn: g.bidPromiseLot?.turn ?? g.turn,
|
||||
lot: g.bidPromiseLot?.lot ?? 1, minimum: bidMinimum };
|
||||
@@ -98,6 +102,7 @@ export function Truthtrance({
|
||||
}
|
||||
: kind === 'traitorCall' ? { kind, target, battle: g.battle?.event ?? '', territory: g.battle?.territory ?? '' }
|
||||
: kind === 'movement' ? { kind, target, ...movement }
|
||||
: kind === 'revival' ? { kind, target, ...revivalClaim }
|
||||
: kind === 'bid' ? { kind, target, ...bidClaim }
|
||||
: kind === 'shipment'
|
||||
? {
|
||||
@@ -206,6 +211,13 @@ export function Truthtrance({
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : w.question!.kind === 'revival' ? (
|
||||
<>
|
||||
<p className="notice">Yes requires the chosen total of future ordinary force returns while possible. No forbids reaching that total, including split requests. Only completed returns after this answer count. Keep the original revival funding and finish all remaining Yes answers before ending Revival.</p>
|
||||
{(g.truthRevivalAnswers ?? []).map(answer => <div key={answer}>
|
||||
{button(`Answer ${answer === 'yes' ? 'Yes' : answer === 'no' ? 'No' : 'I don’t know'}`, { type: 'truthAnswer', answer })}
|
||||
</div>)}
|
||||
</>
|
||||
) : w.question!.kind === 'bid' ? (
|
||||
<>
|
||||
<p className="notice">Yes requires one qualifying bid in this ordinary lot while possible. No forbids qualifying bids until it closes. A bid does not promise a win; winning still requires the original payment. Keep the funding needed by your answer.</p>
|
||||
@@ -330,6 +342,7 @@ export function Truthtrance({
|
||||
Verified current facts
|
||||
</option>
|
||||
<option value="movement">Bind ground movement this turn</option>
|
||||
<option value="revival">Bind ordinary force revivals this phase</option>
|
||||
<option value="bid">Bind a bid in this ordinary lot</option>
|
||||
<option value="traitorCall">Bind this battle’s Traitor declaration</option>
|
||||
<option value="shipment">
|
||||
@@ -346,7 +359,15 @@ export function Truthtrance({
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
{kind === 'bid' ? (
|
||||
{kind === 'revival' ? (
|
||||
<>
|
||||
<RevivalClaimFields value={revivalClaim} disabled={busy || !g.truthRevivalTargets.includes(target)}
|
||||
onChange={claim => setRevivalSelection({ counter: claim.counter, minimum: claim.minimum })} />
|
||||
<p className="fine">Count physical total, normal or elite counters returned by ordinary native Revival after the answer. Smaller requests add together. Earlier returns, Ghola, Emperor-funded extras and other special returns do not count. Current native limits, prices and response choices remain.</p>
|
||||
{!g.truthRevivalTargets.includes(target) && <p className="notice">Choose a respondent whose native opportunity remains in the current Revival phase and finish pending decisions. Registered native Basic and Advanced tables without optional modules are supported first.</p>}
|
||||
<p className="notice" aria-label="Public revival question preview">{truthQuestionText(question, leaderName, locationName)}</p>
|
||||
</>
|
||||
) : kind === 'bid' ? (
|
||||
<>
|
||||
<BidClaimFields value={bidClaim} disabled={busy || !g.bidPromiseLot}
|
||||
onChange={claim => setBidMinimum(claim.minimum)} />
|
||||
@@ -451,6 +472,7 @@ export function Truthtrance({
|
||||
'Ask publicly',
|
||||
{ type: 'truthAsk', question },
|
||||
(kind === 'movement' && (!validMovementClaim(movement) || !g.truthMovementTargets.includes(target))) ||
|
||||
(kind === 'revival' && (!!revivalClaimInputError(revivalClaim) || !g.truthRevivalTargets.includes(target))) ||
|
||||
(kind === 'bid' && (!!bidClaimInputError(bidClaim) || !g.truthBidTargets.includes(target))) ||
|
||||
(kind === 'traitorCall' && !g.truthTraitorTargets.includes(target)) ||
|
||||
(kind === 'mixedShipment' &&
|
||||
|
||||
@@ -16,6 +16,41 @@ open mode gates.
|
||||
|
||||
|
||||
## Current checkpoint and work
|
||||
**Truthtrance / native force-revival commitments — 26 October. Prototyped / Partial.**
|
||||
Registered bare native Basic/Advanced Revival now supports cumulative future
|
||||
physical total/normal/elite returns. Only actual ordinary returns after the
|
||||
answer count; native quotas, free/paid prices, typed Tanks, elite limits,
|
||||
responses and ordered ownership remain. Split requests do not bypass No;
|
||||
voluntary spending/Finish cannot evade Yes. Earlier shipment/movement promises
|
||||
retain their funding in either answer order. Timing/counting is provisional;
|
||||
optional modules, earlier-phase and special/card revival claims remain due.
|
||||
|
||||
Root shared integration with disjoint pure/control/native lanes passes
|
||||
**22 files,288/288**, types/lint/build and **five native paths**,20 GameTable SSR
|
||||
renders/five actual guide callbacks. Original public H/A/E Basic53/Advanced58
|
||||
sources use actual E3 desert shipment for6 and real next-Storm losses before
|
||||
turn2 Revival. Another9/8 actions bind and return3:1 free plus4 paid, including
|
||||
one physical Sardaukar in Advanced. All four minimal profiles submit a legal
|
||||
native return in both source modes. An Advanced No2 path returns1 and finishes
|
||||
below its cap in8 actions.
|
||||
|
||||
Original native Tleilaxu/Ix47 sources use143 actions and an actually held Karama.
|
||||
Another19 allowed or15 stopped actions retain pending progress0, then either
|
||||
real typed return3/payment4 or opposing special prevention/release with no
|
||||
return/payment. Original33/47 cards remain. Seventeen new native cases plus
|
||||
pure10/control6/funding2 cover cumulative and typed constraints, prior/Ghola
|
||||
exclusion, two original Truth copies, Basic post-Ready No, waiting ownership,
|
||||
stale callbacks and earlier/later shipment funding. No played hand, force,
|
||||
Tanks, purse, clock, question or promise is installed in these native sources.
|
||||
One observed older Nexus Fremen fixture now waits for its actual ordered actor.
|
||||
|
||||
Frozen v2 report/manifest:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261026-truth-revival-proof-v2/`;
|
||||
journeys/HTML: adjacent `20261026-truth-revival-runtime/`.
|
||||
No saved-room/D1/server, reset/restart, deployment or assurance campaign.
|
||||
SSR/callback proof leaves browser/mobile/deployed and complete-mode acceptance
|
||||
open. [Source, controls and boundaries](TRUTHTRANCE_REVIVAL_PROMISES.md).
|
||||
|
||||
**Truthtrance / ordinary bid commitments — 26 October. Prototyped / Partial.**
|
||||
Registered bare native Basic/Advanced ordinary lots now support a structured
|
||||
future minimum bid. Actual declarations fulfill Yes; No persists to exact lot
|
||||
|
||||
@@ -1,5 +1,41 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 26 October 2026 — Truthtrance native force-revival commitments
|
||||
|
||||
**Prototyped / Partial.** Cumulative future ordinary native force returns now
|
||||
support physical total/normal/elite Yes/No questions in the current Revival
|
||||
phase. Actual completion records progress; requests, earlier returns, leaders,
|
||||
Ghola and Emperor-funded extras remain separate. Original quotas/costs/Tanks,
|
||||
elite limits, responses and ordered ownership remain. Persistent fields,
|
||||
private feasible answers and event-bound native guides support humans and all
|
||||
four minimal policies. A No threshold applies across split requests; unmet Yes
|
||||
retains funding and blocks voluntary Finish. Earlier/later shipment questions
|
||||
remain jointly feasible. Basic post-Ready ordinary returns below No stay legal.
|
||||
The current-phase/counting interpretation is provisional; broader promises and
|
||||
optional compositions remain required. No game-start gate changes.
|
||||
|
||||
Root integration and disjoint model/control/native lanes pass **22 files,
|
||||
288/288**, types/lint/build and **five native paths**,20 GameTable SSR renders
|
||||
and five actual guide callbacks. Original H/A/E sources use53Basic/58Advanced
|
||||
actions: E really ships3 for6, loses them to the next actual Storm and enters
|
||||
turn2 Revival with4 spice. Nine/eight further actions return3 for1free+4paid,
|
||||
including one Advanced physical Sardaukar. Four minimal policies per source
|
||||
submit a legal native action. An8-action Advanced No2 path returns1 and finishes.
|
||||
|
||||
Native Tleilaxu/Ix47 sources use143 actions and its actually held Karama;
|
||||
another19/15 allow/prevent paths keep pending progress0 before either actual
|
||||
return/payment or opposing prevention/release. Original33/47 Treachery cards
|
||||
remain, with no installed played hand, force/Tanks, purse, clock or promise.
|
||||
Seventeen native,10pure,6control and2funding cases retain typed/split/prior
|
||||
semantics, two original copies, actual Ghola exclusions, post-Ready Basic No,
|
||||
waiting ownership and stale callbacks. An observed older Nexus Fremen fixture
|
||||
now waits for its actual native Advanced actor. Frozen v2 report/manifest:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261026-truth-revival-proof-v2/`;
|
||||
journeys/HTML: adjacent `20261026-truth-revival-runtime/`.
|
||||
No saved-room/D1/server/reset/restart/deployment or assurance campaign.
|
||||
Browser/mobile/deployed, full combinations and complete rules remain open.
|
||||
[Source and contract](TRUTHTRANCE_REVIVAL_PROMISES.md).
|
||||
|
||||
## 26 October 2026 — Truthtrance ordinary bid commitments
|
||||
|
||||
**Prototyped / Partial.** Registered bare native
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
# Rule decision index
|
||||
|
||||
## Truthtrance native force-revival commitments — 26 October 2026
|
||||
|
||||
Apply general current-turn Truthtrance authority (November2020 FAQ p8; supplied
|
||||
Advanced replacement card physical39) to cumulative future ordinary force
|
||||
returns during the current native Revival phase. Under standing 14 October
|
||||
choose-recommended priority, count completed physical total/normal/elite returns
|
||||
after each answer, retaining original free/paid quotas, typed Tanks, prices and
|
||||
response ownership. Earlier returns, leaders, Ghola and Emperor-funded extras
|
||||
do not satisfy this specifically ordinary-force question.
|
||||
|
||||
Current-phase cumulative counting and accepted-order release are provisional
|
||||
software compositions, not a dedicated publisher revival ruling. Waiting
|
||||
participants may answer; only the actual native actor receives a guide. Basic
|
||||
No persists to phase close because readiness can reopen; stamped Advanced No
|
||||
ends at the original opportunity close. Own voluntary actions cannot erase Yes;
|
||||
completed opposing effects may release only impossible answers. No start gate
|
||||
changes; optional modules and broader revival/action claims remain required.
|
||||
[Contract and evidence](TRUTHTRANCE_REVIVAL_PROMISES.md).
|
||||
|
||||
## Truthtrance ordinary bid commitments — 26 October 2026
|
||||
|
||||
Apply the existing November2020 FAQ p8 current-turn commitment authority to one
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
future ordinary bid declaration of at least a chosen positive whole amount in
|
||||
the current lot. Registered native Basic/Advanced tables use their selected
|
||||
decks and ordinary auctions. Optional modules, independent cards, Semuta/Kull,
|
||||
native special auctions, phase-wide/grouped bidding claims and force-revival
|
||||
promises remain required work. This does not change game-start permissions.
|
||||
native special auctions and phase-wide/grouped bidding claims remain required
|
||||
work. The [native force-revival follow-up](TRUTHTRANCE_REVIVAL_PROMISES.md) adds
|
||||
current-phase ordinary returns; broader revival promises remain due. This does not change game-start permissions.
|
||||
|
||||
## Authority and selected interpretation
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
The [26 October ordinary-bid follow-up](TRUTHTRANCE_BID_PROMISES.md) adds a
|
||||
structured current-lot bid commitment. The historical absence table below is
|
||||
superseded for that slice, as well as the previously linked fact, shipment and
|
||||
movement work. Native force-revival promises and broader action commitments
|
||||
movement work. The [native force-revival follow-up](TRUTHTRANCE_REVIVAL_PROMISES.md)
|
||||
adds current-phase ordinary physical returns; broader revival/action commitments
|
||||
remain unfinished; do not reimplement existing recorded-knowledge predicates.
|
||||
|
||||
## Grouped battle-plan authoring — 26 October 2026
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
# Truthtrance native force-revival promises
|
||||
|
||||
## Current-phase prototype — 26 October 2026
|
||||
|
||||
**Prototyped / Partial.** A structured question binds cumulative future ordinary
|
||||
force returns in the current Revival phase, for registered bare native Basic
|
||||
and Advanced tables. Choose total, normal or elite physical counters and a
|
||||
minimum from1 to20. This adds no game-start permission. Optional modules,
|
||||
earlier-phase questions, grouped revival expressions and special/card revival
|
||||
claims remain required work.
|
||||
|
||||
## Authority and selected interpretation
|
||||
|
||||
The [November2020 FAQ p8](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf#page=8)
|
||||
supplies general current-turn question/compliance/impossibility authority;
|
||||
its previously checked indexed text is recorded in the
|
||||
[ordinary-bid contract](TRUTHTRANCE_BID_PROMISES.md). A direct fetch during this
|
||||
slice returned403, so it supplies no newly checked dedicated revival ruling.
|
||||
The user-authorized Advanced source, physical39 replacement Truthtrance text,
|
||||
also requires compliance during the current turn and permits later impossibility
|
||||
release. That local passage was read for this slice. Original native revival
|
||||
quotes and [Advanced turn order](ADVANCED_REVIVAL_ORDER.md) remain authoritative.
|
||||
|
||||
Under standing14October choose-recommended authority, select an explicit
|
||||
**cumulative future ordinary-return** question. Count completed returns after
|
||||
each definite answer. Splitting a return does not avoid No. Each elite is one
|
||||
physical counter; normal means amount minus elites. Already completed returns,
|
||||
leaders, Ghola, Emperor-funded extras and other special returns are excluded
|
||||
because the question specifically names ordinary force revival. Actual native
|
||||
requests, responses, quotes, force transfers and payments remain unchanged.
|
||||
|
||||
The phase interval/counting and accepted-order impossibility policy are
|
||||
provisional software compositions, not a dedicated publisher clarification.
|
||||
Waiting or current participants with a remaining opportunity can answer.
|
||||
Basic No persists through phase close because readiness can reopen. An ordered
|
||||
Advanced participant's No finishes when its native opportunity closes.
|
||||
An unmet Yes prevents voluntary Finish Revival and spending or requests that
|
||||
would remove the remaining completion. Completed opposing consequences can
|
||||
release only impossible Yes answers, retaining No and feasible answers in
|
||||
accepted order. A declaration alone never fulfills a promised return.
|
||||
|
||||
## Connected behavior
|
||||
|
||||
`game/revival-promises.ts` owns bounded canonical claims, typed physical counting,
|
||||
immutable cumulative progress and joint Yes/No checks. The packet is
|
||||
`{kind:'revival',target,turn,counter,minimum}` inside the original `truthAsk`.
|
||||
Normal declaration, priority, answer, physical discard and continuation remain.
|
||||
Each accepted answer has a distinct original event and begins at progress0.
|
||||
Only the respondent receives feasible answer choices and its own promises/guide.
|
||||
Public question and answer history remains public.
|
||||
|
||||
The original native force-request quote is shared with a bounded guide search.
|
||||
It retains real typed Tanks, remaining normal/free/elite allowances, actual
|
||||
prices and Ixian free-Cyborg allocation. No unknown gift, future draw or foreign
|
||||
choice supplies resources. Earlier shipment/movement obligations constrain the
|
||||
quoted final return. The guide sends the original `revive` or `ready` packet
|
||||
with its own event and waits for actual native turn ownership. Manual requests
|
||||
retain their original shape. Stale guided events reject.
|
||||
|
||||
The question selector, validated physical-counter fields, public preview,
|
||||
target-only answer buttons and persistent promise panel are connected. The
|
||||
panel shows progress, fulfillment and release; it does not time out. All four
|
||||
existing minimal AI profiles use the same projected answer and native guide.
|
||||
Full AI strategy/calibration remains deferred.
|
||||
|
||||
## Evidence and remaining gates
|
||||
|
||||
Focused pure, original-deal/death runtime and control evidence is recorded in
|
||||
`tests/revival-promises.test.ts`, `tests/fixture-truth-revival.ts`,
|
||||
`tests/truth-revival-runtime.test.ts` and `tests/truth-revival-controls.test.tsx`.
|
||||
Frozen v2 proof passes22files/288cases, `npm run check:quick` and `npm run build`.
|
||||
Five native engine/GameTable SSR paths render20 tables and exercise five actual
|
||||
hook-free guide callbacks. Original H/A/E Basic53/Advanced58-action sources
|
||||
actually ship3 for6, lose3 to the next Storm and reach turn2 Revival with4spice.
|
||||
Another9/8 actions bind/return3 with1free and4paid; Advanced returns one actual
|
||||
Sardaukar counter. All four minimal policies per source submit a legal return.
|
||||
An8-action Advanced No2 path returns1 and finishes below its threshold.
|
||||
|
||||
Original native Tleilaxu/Ix47 sources use143 actions;19 allowed or15 stopped
|
||||
actions retain pending progress0, then either real return3/payment4 or actual
|
||||
opposing special-Karama prevention/release with no return/payment. Original33/47
|
||||
cards remain. Seventeen native,10pure,6control and2funding cases cover actual
|
||||
return/typed/split/prior semantics, two original Truth copies, Ghola exclusions,
|
||||
Basic post-Ready No, waiting ownership, stale callbacks and shipment funding in
|
||||
both answer orders. One observed older Nexus Fremen test now follows real
|
||||
ordered readiness rather than taking a native return out of turn.
|
||||
|
||||
Report/manifest:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261026-truth-revival-proof-v2/`;
|
||||
journeys/HTML: adjacent `20261026-truth-revival-runtime/`.
|
||||
The final documentation overlay corrects one rules-reference related-topic
|
||||
anchor and records this evidence; the exact anchor replacement is asserted.
|
||||
|
||||
No complete faction/mode, all combinations,
|
||||
recovery/privacy/custody, browser/mobile or deployed acceptance is certified.
|
||||
No saved game, database, server or deployment is changed by this prototype.
|
||||
@@ -1307,6 +1307,7 @@ function policyActions(g: GameView): Action[] {
|
||||
? [{ type: 'decision', event: search.event, card: ordered[0].id }]
|
||||
: [];
|
||||
}
|
||||
if (g.revivalPromiseGuide) return [g.revivalPromiseGuide.action];
|
||||
if (g.bidPromiseGuide) return [g.bidPromiseGuide.action];
|
||||
if (g.truthtrance) {
|
||||
const w = g.truthtrance;
|
||||
@@ -1314,6 +1315,10 @@ function policyActions(g: GameView): Action[] {
|
||||
return w.passed.includes(me.id) ? [] : [{ type: 'truthPass' }];
|
||||
if (w.stage === 'answer') {
|
||||
if (w.question?.target !== me.id) return [];
|
||||
if (w.question.kind === 'revival') {
|
||||
const answer = g.truthRevivalAnswers?.[0];
|
||||
return answer ? [{ type: 'truthAnswer', answer }] : [];
|
||||
}
|
||||
if (w.question.kind === 'bid') {
|
||||
const answer = g.truthBidAnswers?.[0];
|
||||
return answer ? [{ type: 'truthAnswer', answer }] : [];
|
||||
|
||||
+208
-46
@@ -18,6 +18,8 @@ import { findMovementCompletion, type MovementCompletion } from './movement-prom
|
||||
import { classicNexusModulesProfile, mixedE1E2NexusProfile, mixedE3NexusProfile, pairedE3NexusProfile, pairedNexusModulesProfile, singleE1E2NexusProfile, standaloneE3NexusProfile } from './nexus-module-profile';
|
||||
import { independentDiscoveryProfile, independentNexusProfile, nexusCoreProfile, nativeFactionTechProfile, selectedFactionStrongholdProfile, selectedIndependentCardsProfile } from './faction-module-profile';
|
||||
import { homeworldOccupationLeaderSkillsProfile, homeworldOccupationModulesProfile, homeworldPhaseModulesProfile } from './homeworld-module-profile';
|
||||
import { RevivalPromiseError, revivalPromiseModeSupported, liveRevivalPromises, recordPromisedRevival,
|
||||
revivalPromisesSatisfied, validateRevivalPromises, type RevivalClaim, type RevivalPromise } from './revival-promises';
|
||||
import { BidPromiseError, bidPromiseModeSupported, bidPromiseRange, bidRequired, liveBidPromises,
|
||||
recordPromisedBid, validateBidPromises, type BidClaim, type BidOpportunity, type BidPromise } from './bid-promises';
|
||||
import { classicDiscoveryLeaderSkillsProfile, classicDiscoveryNexusProfile, homeworldDiscoveryProfile, discoveryModeSupported, discoveryModuleProfile, mixedE1E2DiscoveryNexusProfile, mixedE3DiscoveryNexusProfile, pairedDiscoveryNexusProfile, pairedE3DiscoveryNexusProfile, singleE1E2DiscoveryNexusProfile, standaloneE3DiscoveryNexusProfile } from './discovery-module-profile';
|
||||
@@ -2136,6 +2138,7 @@ export type Game = {
|
||||
truthHistory?: TruthRecord[];
|
||||
shipmentPromises?: ShipmentPromise[];
|
||||
bidPromises?: BidPromise[];
|
||||
revivalPromises?: RevivalPromise[];
|
||||
movementPromises?: MovementPromise[];
|
||||
/** Server-only certainty of movement-relevant cards still in the discard. */
|
||||
movementDiscardKnowledge?: MovementDiscardKnowledge;
|
||||
@@ -18205,7 +18208,9 @@ function movementTruthAnswers(g: Game, p: Player, claim: MovementClaim): TruthAn
|
||||
for (const answer of [true, false]) {
|
||||
const promise = { ...claim, turn: g.turn, player: p.id, asker: g.players.find(other => other.id !== p.id)!.id, answer };
|
||||
const answerSource = g.phase === 2 || (g.phase === 4 && !g.revivalOrder) ? { ...g, ready: g.ready.filter(id => id !== p.id) } : g;
|
||||
if (groundMovementCompletion(answerSource, p.id, [...live, promise])) answers.push(answer ? 'yes' : 'no');
|
||||
if (groundMovementCompletion(answerSource, p.id, [...live, promise]) &&
|
||||
revivalCompatibleLaterPromises(answerSource, p, { movementPromises: [...(g.movementPromises ?? []), promise] }))
|
||||
answers.push(answer ? 'yes' : 'no');
|
||||
}
|
||||
return answers.length ? answers : ['unknown'];
|
||||
}
|
||||
@@ -26654,7 +26659,8 @@ function shipmentTruthAnswers(
|
||||
// Concurrent legacy readiness can reopen; a completed ordered turn cannot.
|
||||
const answerSource = g.phase === 2 || (g.phase === 4 && !g.revivalOrder)
|
||||
? { ...g, ready: g.ready.filter(id => id !== p.id) } : g;
|
||||
if (findShipmentCompletion(answerSource, p, promises))
|
||||
if (findShipmentCompletion(answerSource, p, promises) &&
|
||||
revivalCompatibleLaterPromises(answerSource, p, { shipmentPromises: [...(g.shipmentPromises ?? []), promises[promises.length - 1]] }))
|
||||
answers.push(answer ? 'yes' : 'no');
|
||||
}
|
||||
return answers.length ? answers : ['unknown'];
|
||||
@@ -29699,6 +29705,8 @@ function finishRevival(
|
||||
const source = revival.nexusEmperorEvent ? 'nexusEmperor' :
|
||||
revival.emperorExtra ? 'emperorExtra' : 'normal';
|
||||
const group = { amount: n, elite, free: revival.free };
|
||||
if (source === 'normal' && liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).length)
|
||||
g.revivalPromises = revivalPromiseRule(() => recordPromisedRevival(g.revivalPromises ?? [], p.id, g.turn, { amount: n, elite }));
|
||||
commitRevivalForcePieces(g, p, revival);
|
||||
finishNexusEmperorRevival(g, revival, 'returned');
|
||||
if (quote.techIncome) {
|
||||
@@ -29761,6 +29769,183 @@ function finishRevival(
|
||||
}
|
||||
}
|
||||
}
|
||||
function normalForceRevivalQuote(g: Game, p: Player, action: Action): PendingRevival {
|
||||
const n = integer(action.amount, 1, forceRevivalRemaining(g, p), 'Forces');
|
||||
const elite = eliteChoice(n, p.tanks, p.elites?.tanks ?? 0, action.elite);
|
||||
requireRule(
|
||||
elite <= eliteRevivalRemaining(p, g.advanced),
|
||||
'Only one elite force may be revived per turn.',
|
||||
);
|
||||
const availableFree = forceRevivalQuote(g, p, n).free;
|
||||
const freeElite =
|
||||
p.faction === 'ixians'
|
||||
? integer(
|
||||
action.freeElite ?? Math.min(availableFree, elite),
|
||||
Math.max(0, availableFree - (n - elite)),
|
||||
Math.min(availableFree, elite),
|
||||
'Free cyborg revivals',
|
||||
)
|
||||
: 0;
|
||||
const quote = forceRevivalQuote(g, p, n, elite, freeElite);
|
||||
requireRule(quote.cost <= p.spice, 'Not enough spice for revival.');
|
||||
const ixSuboidBonus = p.faction === 'ixians' ? quoteIxSuboidBonus({
|
||||
enabled: ixHomeworldRevivalEnabled(g, p.id) && !g.revivalRules?.freeBlocked?.includes(p.id), amount: n, elite,
|
||||
free: quote.free, freeElite,
|
||||
normalTanks: p.tanks - (p.elites?.tanks ?? 0),
|
||||
}) : 0;
|
||||
const checks: PendingRevival['checks'] = [];
|
||||
const choamBenefit = p.faction === 'choam' && !g.revivalRules?.choamBlocked;
|
||||
const uncancelableLimit = Math.max(
|
||||
g.recruits?.turn === g.turn ? 7 : Math.max(3, freeRevivalRate(g, p)),
|
||||
g.revivalRules?.nexusTleilaxu?.includes(p.id) ? 5 : 0);
|
||||
if (choamBenefit && (n > quote.free || p.revived + n > uncancelableLimit))
|
||||
checks.push('choamRevival');
|
||||
if (!choamBenefit && p.revived + n > uncancelableLimit)
|
||||
checks.push('revivalLimit');
|
||||
const factionCost = choamBenefit ? n - quote.free : quote.normalCost;
|
||||
if (quote.cost < factionCost) checks.push('revivalDiscount');
|
||||
return {
|
||||
player: p.id,
|
||||
kind: 'forces',
|
||||
amount: n,
|
||||
elite,
|
||||
...quote,
|
||||
...(ixSuboidBonus ? { ixSuboidBonus } : {}),
|
||||
checks,
|
||||
};
|
||||
}
|
||||
function revivalPromiseRule<T>(calculate: () => T): T {
|
||||
try { return calculate(); }
|
||||
catch (error) { if (error instanceof RevivalPromiseError) throw new RuleError(error.message); throw error; }
|
||||
}
|
||||
function revivalPromiseIntegrity(g: Game) {
|
||||
revivalPromiseRule(() => validateRevivalPromises(g.revivalPromises ?? [], g.players.map(player => player.id)));
|
||||
}
|
||||
/** The current native phase only; a waiting ordered participant still owns a
|
||||
* future opportunity. Basic readiness may reopen, so No lasts to phase close. */
|
||||
function revivalOpportunity(g: Game, player: string): boolean {
|
||||
return g.status === 'playing' && g.phase === 4 && revivalPromiseModeSupported(g) &&
|
||||
!g.ready.includes(player) && (!g.revivalOrder || g.revivalOrder.remaining.includes(player));
|
||||
}
|
||||
function revivalQuestionWindow(g: Game): boolean {
|
||||
return g.status === 'playing' && g.phase === 4 && revivalPromiseModeSupported(g) &&
|
||||
!g.response && !g.decision && !g.phaseOpening && !g.pendingRevival &&
|
||||
!g.pendingKarama && !g.pendingTreacheryDiscard;
|
||||
}
|
||||
function revivalKeepsLaterPromises(g: Game, p: Player, revival?: PendingRevival): boolean {
|
||||
if (!liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length &&
|
||||
!liveMovementPromises(g.movementPromises ?? [], p.id, g.turn).length) return true;
|
||||
const trial = structuredClone(g), actor = getPlayer(trial, p.id);
|
||||
trial.truthtrance = null;
|
||||
if (revival) {
|
||||
actor.spice -= revival.cost;
|
||||
commitRevivalForcePieces(trial, actor, revival);
|
||||
}
|
||||
// This is the final aggregate: later witnesses cannot invent another ordinary
|
||||
// revival that would bypass this question's typed No or paid-return budget.
|
||||
if (!trial.ready.includes(p.id)) trial.ready.push(p.id);
|
||||
try {
|
||||
const action = { type: 'ready' };
|
||||
reconcileShipmentPromises(trial, { actor: p.id, action, phase: g.phase });
|
||||
reconcileGroundMovementPromises(trial, p.id, action, g.phase);
|
||||
return true;
|
||||
} catch (error) { if (error instanceof RuleError) return false; throw error; }
|
||||
}
|
||||
/** A final aggregate can be requested together, including Ixian free-Cyborg
|
||||
* selection. The native quote owns prices, typed Tanks, free quota and limits;
|
||||
* no future gift, card draw or foreign decision is invented. */
|
||||
function revivalPromiseCompletion(g: Game, p: Player, promises = g.revivalPromises ?? []): Action | null {
|
||||
if (!revivalOpportunity(g, p.id)) return null;
|
||||
if (revivalPromisesSatisfied(promises, p.id, g.turn, { amount: 0, elite: 0 }) &&
|
||||
revivalKeepsLaterPromises(g, p)) return { type: 'ready' };
|
||||
if (revivalPrevented(g, p.id)) return null;
|
||||
const maximum = forceRevivalRemaining(g, p), eliteTanks = p.elites?.tanks ?? 0;
|
||||
for (let amount = 1; amount <= maximum; amount++)
|
||||
for (let elite = Math.max(0, amount - (p.tanks - eliteTanks)); elite <= Math.min(amount, eliteRevivalRemaining(p, g.advanced)); elite++) {
|
||||
if (!revivalPromisesSatisfied(promises, p.id, g.turn, { amount, elite })) continue;
|
||||
const free = forceRevivalQuote(g, p, amount).free;
|
||||
for (let freeElite = p.faction === 'ixians' ? Math.max(0, free - (amount - elite)) : 0;
|
||||
freeElite <= (p.faction === 'ixians' ? Math.min(free, elite) : 0); freeElite++) {
|
||||
const action: Action = { type: 'revive', amount, elite, ...(p.faction === 'ixians' ? { freeElite } : {}) };
|
||||
try {
|
||||
const revival = normalForceRevivalQuote(g, p, action);
|
||||
if (revivalKeepsLaterPromises(g, p, revival)) return action;
|
||||
} catch (error) { if (!(error instanceof RuleError)) throw error; }
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
function revivalCompatibleLaterPromises(g: Game, p: Player, later: Pick<Partial<Game>, 'shipmentPromises' | 'movementPromises'>): boolean {
|
||||
if (!liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).length) return true;
|
||||
const trial = { ...g, ...later };
|
||||
return !!revivalPromiseCompletion(trial, getPlayer(trial, p.id));
|
||||
}
|
||||
function revivalTruthAnswers(g: Game, p: Player, claim: RevivalClaim): ('yes' | 'no' | 'unknown')[] {
|
||||
if (!revivalQuestionWindow(g) || !revivalOpportunity(g, p.id) || claim.turn !== g.turn) return [];
|
||||
return (['yes', 'no'] as const).filter(answer => !!revivalPromiseCompletion(g, p, [...(g.revivalPromises ?? []),
|
||||
{ ...claim, event: 'candidate', player: p.id, asker: g.truthtrance?.queue[0]?.player ?? g.host,
|
||||
answer: answer === 'yes', progress: 0 }]));
|
||||
}
|
||||
function bindRevivalTruth(g: Game, p: Player, claim: RevivalClaim, answer: boolean, asker: string) {
|
||||
requireRule(revivalTruthAnswers(g, p, claim).includes(answer ? 'yes' : 'no'),
|
||||
'This revival answer no longer has a legal completion.');
|
||||
(g.revivalPromises ??= []).push({ ...claim, event: crypto.randomUUID(), player: p.id, asker, answer, progress: 0 });
|
||||
}
|
||||
function reconcileRevivalPromises(g: Game, voluntary?: { actor: string; action: Action }) {
|
||||
if (!g.revivalPromises?.some(promise => !promise.fulfilled && !promise.released)) return;
|
||||
revivalPromiseIntegrity(g);
|
||||
if (g.truthtrance || g.response || g.decision || g.phaseOpening || g.pendingRevival ||
|
||||
g.pendingTreacheryDiscard || g.pendingKarama || g.pendingKull || g.pendingRicheseBetrayal) return;
|
||||
const kept: RevivalPromise[] = [];
|
||||
const no = g.revivalPromises.filter(promise => !promise.answer && !promise.fulfilled && !promise.released);
|
||||
for (const promise of g.revivalPromises) {
|
||||
if (promise.fulfilled || promise.released) continue;
|
||||
const same = g.status === 'playing' && g.phase === 4 && g.turn === promise.turn;
|
||||
if (!promise.answer) {
|
||||
if (!same || g.revivalOrder && !g.revivalOrder.remaining.includes(promise.player)) promise.fulfilled = true;
|
||||
continue;
|
||||
}
|
||||
if (!same || !revivalPromiseCompletion(g, getPlayer(g, promise.player), [...no, ...kept, promise])) {
|
||||
requireRule(voluntary?.actor !== promise.player,
|
||||
'You cannot voluntarily evade your Truthtrance revival answer.');
|
||||
promise.released = true;
|
||||
log(g, `${getPlayer(g, promise.player).name} can no longer fulfill the Truthtrance revival answer; that answer is no longer binding.`);
|
||||
} else kept.push(promise);
|
||||
}
|
||||
}
|
||||
function projectedRevivalPromiseGuide(g: Game, player: string): { action: Action } | null {
|
||||
if (!revivalQuestionWindow(g) || g.truthtrance || !revivalOpportunity(g, player) ||
|
||||
nativeRevivalOrderBlock(g, player)) return null;
|
||||
const promise = liveRevivalPromises(g.revivalPromises ?? [], player, g.turn)[0];
|
||||
if (!promise) return null;
|
||||
const action = revivalPromiseCompletion(g, getPlayer(g, player));
|
||||
return action ? { action: { ...action, revivalPromiseEvent: promise.event } } : null;
|
||||
}
|
||||
function guardPromisedRevival(g: Game, p: Player, action: Action, revival?: PendingRevival) {
|
||||
const live = liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn);
|
||||
if (action.revivalPromiseEvent !== undefined) requireRule(revivalOpportunity(g, p.id) &&
|
||||
typeof action.revivalPromiseEvent === 'string' && live.some(promise => promise.event === action.revivalPromiseEvent),
|
||||
'This revival commitment guide is stale.');
|
||||
if (!live.length) return;
|
||||
if (!revival) {
|
||||
requireRule(revivalPromisesSatisfied(g.revivalPromises ?? [], p.id, g.turn, { amount: 0, elite: 0 }),
|
||||
'Complete the force returns required by your Truthtrance Yes before finishing Revival.');
|
||||
return;
|
||||
}
|
||||
// Preflight a voluntary request against its actual current invoice. Only the
|
||||
// completed real return below records progress; an opposing stop records none.
|
||||
const trial = structuredClone(g), actor = getPlayer(trial, p.id);
|
||||
trial.truthtrance = null;
|
||||
trial.revivalPromises = revivalPromiseRule(() => recordPromisedRevival(trial.revivalPromises ?? [], p.id,
|
||||
g.turn, { amount: revival.amount!, elite: revival.elite ?? 0 }));
|
||||
// Legacy concurrent Ready does not forbid a later native request. Its No
|
||||
// stays binding to phase close; only the detached completion probe reopens.
|
||||
if (!trial.revivalOrder) trial.ready = trial.ready.filter(player => player !== p.id);
|
||||
actor.spice -= revival.cost;
|
||||
commitRevivalForcePieces(trial, actor, revival);
|
||||
requireRule(!!revivalPromiseCompletion(trial, actor),
|
||||
'This request would prevent you from completing your Truthtrance revival answer.');
|
||||
}
|
||||
function beginRevival(g: Game, revival: PendingRevival) {
|
||||
requireRule(
|
||||
!revivalPrevented(g, revival.player),
|
||||
@@ -34568,6 +34753,7 @@ function normalizeCardNames(g: Game) {
|
||||
}
|
||||
export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
bidPromiseIntegrity(state);
|
||||
revivalPromiseIntegrity(state);
|
||||
revivalOrderIntegrity(state);
|
||||
state = migrateAtomicsTargets(state);
|
||||
state = migrateFremenNexusRevival(state);
|
||||
@@ -34671,8 +34857,11 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
);
|
||||
return normalizeAutomaticGame(state);
|
||||
}
|
||||
if (action.revivalPromiseEvent !== undefined) requireRule(action.type === 'revive' || action.type === 'ready',
|
||||
'This revival commitment guide cannot authorize another action.');
|
||||
const g = applyActionInner(state, id, action);
|
||||
reconcileBidPromises(g, { actor: id, action });
|
||||
reconcileRevivalPromises(g, { actor: id, action });
|
||||
reconcileGroundMovementPromises(g, id, action, state.phase);
|
||||
reconcileTraitorTruth(g, id, action);
|
||||
harkonnenNexusBetrayalIntegrity(g);
|
||||
@@ -34795,6 +34984,7 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
reconcileBattlePromises(g, { actor: id, action });
|
||||
reconcileShipmentPromises(g, { actor: id, action, phase: state.phase });
|
||||
reconcileBidPromises(g, { actor: id, action });
|
||||
reconcileRevivalPromises(g, { actor: id, action });
|
||||
settleAutomaticContinuations(g);
|
||||
observeOccupation(g);
|
||||
reconcileHomeworldEcazDuke(g);
|
||||
@@ -34996,6 +35186,7 @@ function settleAutomaticContinuations(g: Game) {
|
||||
reconcileBattlePromises(g);
|
||||
reconcileShipmentPromises(g);
|
||||
reconcileBidPromises(g);
|
||||
reconcileRevivalPromises(g);
|
||||
reconcileGroundMovementPromises(g);
|
||||
reconcileTraitorTruth(g);
|
||||
requireRule(
|
||||
@@ -35010,6 +35201,7 @@ function settleAutomaticContinuations(g: Game) {
|
||||
/** Internal authoritative continuation. Callers must persist with their usual CAS fence. */
|
||||
export function normalizeAutomaticGame(state: Game): Game {
|
||||
bidPromiseIntegrity(state);
|
||||
revivalPromiseIntegrity(state);
|
||||
state = migrateAtomicsTargets(state);
|
||||
state = migrateFremenNexusRevival(state);
|
||||
harkonnenNexusBetrayalIntegrity(state);
|
||||
@@ -35094,6 +35286,7 @@ export function normalizeAutomaticGame(state: Game): Game {
|
||||
reconcileBattlePromises(g);
|
||||
reconcileShipmentPromises(g);
|
||||
reconcileBidPromises(g);
|
||||
reconcileRevivalPromises(g);
|
||||
reconcileGroundMovementPromises(g);
|
||||
reconcileTraitorTruth(g);
|
||||
settleAutomaticContinuations(g);
|
||||
@@ -35339,6 +35532,8 @@ function applyActionInner(
|
||||
bindShipment: bindShipmentTruth,
|
||||
movementAnswers: movementTruthAnswers,
|
||||
bindMovement: bindMovementTruth,
|
||||
revivalAnswers: revivalTruthAnswers,
|
||||
bindRevival: bindRevivalTruth,
|
||||
bidAnswers: bidTruthAnswers,
|
||||
bindBid: bindBidTruth,
|
||||
traitorAnswers: traitorTruthAnswers,
|
||||
@@ -35350,7 +35545,7 @@ function applyActionInner(
|
||||
)
|
||||
return g;
|
||||
} catch (error) {
|
||||
if (error instanceof TruthError || error instanceof BidPromiseError || error instanceof PlanClaimError || error instanceof MovementClaimError || error instanceof TraitorDecisionError)
|
||||
if (error instanceof TruthError || error instanceof RevivalPromiseError || error instanceof BidPromiseError || error instanceof PlanClaimError || error instanceof MovementClaimError || error instanceof TraitorDecisionError)
|
||||
throw new RuleError(error.message);
|
||||
throw error;
|
||||
}
|
||||
@@ -38104,49 +38299,9 @@ function applyActionInner(
|
||||
if (t === 'revive') {
|
||||
requireRule(g.phase === 4, 'Revival is not the current phase.');
|
||||
if (execution === 'live') startNativeRevivalOrder(g,id);
|
||||
const n = integer(action.amount, 1, forceRevivalRemaining(g, p), 'Forces');
|
||||
const elite = eliteChoice(n, p.tanks, p.elites?.tanks ?? 0, action.elite);
|
||||
requireRule(
|
||||
elite <= eliteRevivalRemaining(p, g.advanced),
|
||||
'Only one elite force may be revived per turn.',
|
||||
);
|
||||
const availableFree = forceRevivalQuote(g, p, n).free;
|
||||
const freeElite =
|
||||
p.faction === 'ixians'
|
||||
? integer(
|
||||
action.freeElite ?? Math.min(availableFree, elite),
|
||||
Math.max(0, availableFree - (n - elite)),
|
||||
Math.min(availableFree, elite),
|
||||
'Free cyborg revivals',
|
||||
)
|
||||
: 0;
|
||||
const quote = forceRevivalQuote(g, p, n, elite, freeElite);
|
||||
requireRule(quote.cost <= p.spice, 'Not enough spice for revival.');
|
||||
const ixSuboidBonus = p.faction === 'ixians' ? quoteIxSuboidBonus({
|
||||
enabled: ixHomeworldRevivalEnabled(g, id) && !g.revivalRules?.freeBlocked?.includes(id), amount: n, elite,
|
||||
free: quote.free, freeElite,
|
||||
normalTanks: p.tanks - (p.elites?.tanks ?? 0),
|
||||
}) : 0;
|
||||
const checks: PendingRevival['checks'] = [];
|
||||
const choamBenefit = p.faction === 'choam' && !g.revivalRules?.choamBlocked;
|
||||
const uncancelableLimit = Math.max(
|
||||
g.recruits?.turn === g.turn ? 7 : Math.max(3, freeRevivalRate(g, p)),
|
||||
g.revivalRules?.nexusTleilaxu?.includes(p.id) ? 5 : 0);
|
||||
if (choamBenefit && (n > quote.free || p.revived + n > uncancelableLimit))
|
||||
checks.push('choamRevival');
|
||||
if (!choamBenefit && p.revived + n > uncancelableLimit)
|
||||
checks.push('revivalLimit');
|
||||
const factionCost = choamBenefit ? n - quote.free : quote.normalCost;
|
||||
if (quote.cost < factionCost) checks.push('revivalDiscount');
|
||||
beginRevival(g, {
|
||||
player: id,
|
||||
kind: 'forces',
|
||||
amount: n,
|
||||
elite,
|
||||
...quote,
|
||||
...(ixSuboidBonus ? { ixSuboidBonus } : {}),
|
||||
checks,
|
||||
});
|
||||
const revival = normalForceRevivalQuote(g, p, action);
|
||||
if (execution === 'live') guardPromisedRevival(g, p, action, revival);
|
||||
beginRevival(g, revival);
|
||||
return g;
|
||||
}
|
||||
if (t === 'reviveKwisatz' || t === 'reviveLeader') {
|
||||
@@ -39109,6 +39264,8 @@ function applyActionInner(
|
||||
return g;
|
||||
}
|
||||
if (t === 'ready') {
|
||||
if (g.phase === 4) guardPromisedRevival(g, p, action);
|
||||
else requireRule(action.revivalPromiseEvent === undefined, 'This revival commitment guide is stale.');
|
||||
requireRule(
|
||||
[1, 2, 4, 7, 8].includes(g.phase) ||
|
||||
(g.phase === 0 && g.stormPending !== null),
|
||||
@@ -39302,6 +39459,11 @@ export function viewGame(state: Game, id: string) {
|
||||
}).map(player => player.id) : [],
|
||||
truthBidAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
|
||||
g.truthtrance.question.kind === 'bid' ? bidTruthAnswers(g, me, g.truthtrance.question) : null,
|
||||
truthRevivalTargets: revivalQuestionWindow(g) ? g.players.filter(player => revivalOpportunity(g, player.id)).map(player => player.id) : [],
|
||||
truthRevivalAnswers: g.truthtrance?.stage === 'answer' && g.truthtrance.question?.target === id &&
|
||||
g.truthtrance.question.kind === 'revival' ? revivalTruthAnswers(g, me, g.truthtrance.question) : null,
|
||||
revivalPromises: (g.revivalPromises ?? []).filter(promise => promise.player === id).map(promise => ({ ...promise })),
|
||||
revivalPromiseGuide: projectedRevivalPromiseGuide(g, id),
|
||||
bidPromises: projectedBidPromises(g, id),
|
||||
bidPromiseGuide: projectedBidPromiseGuide(g, id),
|
||||
truthtrance: g.truthtrance ?? null,
|
||||
|
||||
+29
-2
@@ -3208,6 +3208,31 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{ area: 'Verification', status: 'Partial', detail: '13 files101/101, types/lint/build and six native engine/answer-callback/GameTable SSR journeys pass. Genuine26/72/77-action Basic/allied/Cunning sources earn original cards, alliances, payments, reveal and draw/return. Four profiles answer/vote legally. Unmatched-card staging is labelled; submitted-false and response/typing fixture feedback is repaired. Full future promises, combinations and final assurance remain open.', evidence: ['tests/traitor-decision-promises.test.ts', 'tests/fixture-truth-traitor-decision.ts', 'tests/fixture-truth-traitor-harkonnen.ts', 'tests/truth-traitor-decision-engine.test.ts', 'tests/truth-traitor-harkonnen-engine.test.ts', 'tests/truth-traitor-decision-controls.test.tsx', 'docs/TRUTHTRANCE_TRAITOR_DECISIONS.md'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'truthtrance-revival',
|
||||
title: 'Truthtrance: ordinary force-revival promises',
|
||||
category: 'Cards',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Prototyped',
|
||||
summary: 'Bind cumulative future total, normal or elite physical returns in the current native Revival phase.',
|
||||
steps: [
|
||||
'Choose Bind ordinary force revivals this phase, another participant with a remaining native opportunity, total/normal/elite counters and a minimum from1 to20. This first version supports registered native Basic and Advanced tables without optional modules; it opens no new game-start configuration.',
|
||||
'Only ordinary force returns actually completed after your answer count. Each elite is one physical counter. Earlier returns, leaders, Ghola, Emperor-funded extras and other special returns are separate. Smaller ordinary requests add together.',
|
||||
'Yes requires enough matching returns while possible. No forbids reaching the minimum, even through several smaller requests. Several answers must remain jointly possible. The original typed Tanks, free allowance, paid prices, elite limits and Revival order remain authoritative.',
|
||||
'The private answer choices and guide use current native quotas and funding, including native free/paid selection. Earlier shipment and ground-movement promises retain their funding. Unknown gifts, future draws and foreign choices are not assumed.',
|
||||
'Your own request, spending or Finish Revival cannot evade an unmet Yes. A completed opposing effect may release an impossible answer in accepted order. A requested return does not fulfill Yes until the actual return completes; its original responses still decide the outcome.',
|
||||
'The persistent own promise panel displays progress and fulfilled/released status. It waits for your actual native opportunity before offering a normal revival or Finish Revival. Basic No remains through phase close because native readiness can reopen; stamped Advanced No ends when your opportunity finishes.',
|
||||
'This cumulative current-phase protocol is a provisional online composition of general current-turn Truthtrance authority. Earlier-phase questions, optional-module compositions, special/card revival claims, grouped revival expressions and complete-mode acceptance remain required work.',
|
||||
],
|
||||
related: ['card-truthtrance', 'advanced-revival-order', 'truthtrance-shipment', 'truthtrance-movement', 'implementation-checklist'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Typed cumulative current-phase native ordinary returns, real completion, compatible answers, voluntary guards and forced release retain original cost/quota/order.', evidence: ['game/revival-promises.ts', 'game/truthtrance.ts', 'game/engine.ts', 'tests/revival-promises.test.ts', 'tests/truth-revival-runtime.test.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Structured question, target-only feasible answers and persistent event-bound normal revival/finish guide.', evidence: ['components/revival-claim-fields.tsx', 'components/revival-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx', 'tests/truth-revival-controls.test.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Existing four profiles use feasible answers and the native action guide; strategy and difficulty calibration remain deferred.', evidence: ['game/bots.ts', 'tests/truth-revival-runtime.test.ts'] },
|
||||
{ area: 'Documentation', status: 'Implemented', detail: 'General authority, provisional timing/count interpretation, native order and remaining boundaries are explicit.', evidence: ['docs/TRUTHTRANCE_REVIVAL_PROMISES.md', 'docs/RULE_DECISIONS.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused native original-deal/death, pure and control evidence; broader combinations, browser and final deployed acceptance remain open.', evidence: ['tests/fixture-truth-revival.ts', 'tests/truth-revival-runtime.test.ts', 'tests/truth-revival-controls.test.tsx'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'truthtrance-bid',
|
||||
title: 'Truthtrance: a bid in the current ordinary lot',
|
||||
@@ -3222,7 +3247,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'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.',
|
||||
'Your own voluntary spending cannot evade Yes. A completed opposing raise may make it impossible and release that answer; still-possible answers and No constraints remain. Closed lots finish No and cannot bind the next lot or turn.',
|
||||
'Your persistent bid-promise panel waits for your actual active seat, then offers a legal bid or pass. It shows fulfilled and released status without timing out. Only the questioned player receives its feasible answers and private guide; the question and reply are public.',
|
||||
'This current-lot and next-opportunity protocol is a provisional online composition of the general current-turn Truthtrance rule. Native special auctions, optional modules, grouped/phase-wide bidding commitments, force-revival commitments and complete-mode acceptance remain unfinished.',
|
||||
'This current-lot and next-opportunity protocol is a provisional online composition of the general current-turn Truthtrance rule. Native special auctions, optional modules, grouped/phase-wide bidding commitments, broader force-revival commitments and complete-mode acceptance remain unfinished.',
|
||||
],
|
||||
related: ['card-truthtrance', 'bidding', 'truthtrance-shipment', 'implementation-checklist'],
|
||||
checklist: [
|
||||
@@ -3382,7 +3407,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
category: 'Cards',
|
||||
coverage: 'Partial',
|
||||
summary:
|
||||
'Ask another player a public yes/no question. Verified facts, supported ordinary-bid, shipment and ground-movement promises, current-battle plan and Traitor-declaration commitments, freeform answers and saving an unanswered card are supported; arbitrary promise enforcement is unfinished.',
|
||||
'Ask another player a public yes/no question. Verified facts, supported ordinary-bid, ordinary force-revival, shipment and ground-movement promises, current-battle plan and Traitor-declaration commitments, freeform answers and saving an unanswered card are supported; arbitrary promise enforcement is unfinished.',
|
||||
steps: [
|
||||
'Declare the card from your hand at any time after cards are dealt. Other players may declare a competing Truthtrance or pass; questions are then asked in storm order. The interrupted phase, response, or battle decision resumes afterward.',
|
||||
'Ask a game-related yes/no question. Named-card, selected-traitor, currently unrevealed Face Dancer and current personal-spice questions are checked by the server. Face Dancer and ordinary Traitor holdings stay distinct. AND and OR combine facts; only the combined answer is published, with no extra inventory details or separate answer for each clause.',
|
||||
@@ -3394,6 +3419,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'An answer may bind actions or decisions during the current turn only. The answering player must do everything in their power to comply; if compliance later becomes impossible, the answer is no longer binding. Freeform promises are recorded in the table’s turn history. Arbitrary freeform enforcement and broader action commitments remain unfinished. Structured reserve-shipment promises include earlier bare Basic phases and unused active or waiting phase5 turns in base Basic/Advanced, including Guild; see the shipment promise guide. Ground-movement minima include earlier Basic phases with declared native families, owned Charity/ordinary revival preparation and exact normal/native auction funding, plus active or unused waiting phase5 turns before or after shipment. Yes binds a qualifying event while possible, and No remains binding through later Hajr/Ornithopter until actual turn end. See the separate ground-movement guide and its remaining preparation/timing boundaries.',
|
||||
'After both plans reveal, a separate current-Traitor question binds an actual unsubmitted native voter to Reveal or decline. The declaration fulfills it before later Karama/betrayal prevention. Cunning cannot voluntarily return a matching card needed by a live Yes; completed declarations retain their native later custody rights. See the current-Traitor guide for scope.',
|
||||
'An ordinary-bid question can bind one new qualifying bid in the current lot for a supported active or waiting bidder. Yes requires that declaration while possible; No forbids it to lot close. A bid need not win to fulfill Yes, but a winning bid still follows its original payment rules. See the current-lot guide for funding, expiry and unfinished configurations.',
|
||||
'An ordinary force-revival question binds cumulative future total, normal or elite returns in the current native Revival phase. Earlier and special returns do not count. Yes retains the original costs and limits; No also applies across smaller requests. See the revival-promise guide for exact timing and unfinished compositions.',
|
||||
'Battle-plan questions can bind dial values or ranges, named weapon/defense slots, a leader or either Cheap Hero, spice support and Kwisatz use. The complete grouped builder supports eight children per group, sixteen total conditions including groups and depth four. Add/wrap/remove/collapse retains the whole claim and validates every field. AND/OR combinations preserve legal alternatives. Only answers with a legal completion now or through available Ghola preparation are offered; a sealed plan fixes its answer. Previous battle commitments, Voice and prescience also apply. Private preparation guidance can play a required Ghola, then a compliant-plan example can fill the form for review before sealing.',
|
||||
'Named conditions include Reinforcements and Harass slot occupancy without making either a weapon/defense category. In clean classic Homeworlds, a source-aware legal completion can choose the native fee needed for a promised dial; review and filling retain that exact allocation. Active preview, answers/history and server records use the actual combat location name, including native Worlds, rather than treating every battle as an Arrakis territory.',
|
||||
'If a completed opposing power makes a battle answer impossible, it is publicly released. A pending Voice response resolves before that release. A player cannot voluntarily spend a promised battle card or give away necessary support spice to evade the answer. Feasibility includes available Ghola cards, eligible leader/Kwisatz or force revivals, and possible revival income. Each actual card can be spent only once. Other future card/power sequences and the full timing audit remain unfinished.',
|
||||
@@ -3411,6 +3437,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'truthtrance-shipment',
|
||||
'truthtrance-movement',
|
||||
'truthtrance-bid',
|
||||
'truthtrance-revival',
|
||||
'richese-no-field',
|
||||
'card-reinforcements',
|
||||
],
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { nativePromiseScopeSupported, type NativePromiseScope } from './native-promise-scope';
|
||||
|
||||
export type RevivalClaim = { turn: number; counter: 'total' | 'normal' | 'elite'; minimum: number };
|
||||
export type RevivalPromise = RevivalClaim & {
|
||||
event: string;
|
||||
player: string;
|
||||
asker: string;
|
||||
answer: boolean;
|
||||
/** Physical ordinary force returns after this answer, excluding prior returns. */
|
||||
progress: number;
|
||||
fulfilled?: boolean;
|
||||
released?: boolean;
|
||||
};
|
||||
type RevivalGroup = { amount: number; elite: number };
|
||||
|
||||
export class RevivalPromiseError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'RevivalPromiseError';
|
||||
}
|
||||
}
|
||||
|
||||
export function parseRevivalClaim(value: unknown): RevivalClaim {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value))
|
||||
throw new RevivalPromiseError('Choose a current-turn ordinary force-revival question.');
|
||||
const input = value as Record<string, unknown>;
|
||||
if (typeof input.turn !== 'number' || !Number.isSafeInteger(input.turn) || input.turn < 1 ||
|
||||
(input.counter !== 'total' && input.counter !== 'normal' && input.counter !== 'elite') ||
|
||||
typeof input.minimum !== 'number' || !Number.isSafeInteger(input.minimum) ||
|
||||
input.minimum < 1 || input.minimum > 20)
|
||||
throw new RevivalPromiseError('Choose the current turn, physical counter type and a whole revival minimum from 1 to 20.');
|
||||
return { turn: input.turn, counter: input.counter, minimum: input.minimum };
|
||||
}
|
||||
|
||||
/** The original bare native scope is unchanged. Timing, allowance, payment,
|
||||
* typed Tanks and actual ordinary-return sources remain the engine's rules. */
|
||||
export function revivalPromiseModeSupported(game: NativePromiseScope & {
|
||||
semutaPreview?: unknown; kullPreview?: unknown; nexusKullPreview?: unknown;
|
||||
richeseBetrayalPreview?: unknown;
|
||||
}): boolean {
|
||||
return nativePromiseScopeSupported(game) && !game.semutaPreview && !game.kullPreview &&
|
||||
!game.nexusKullPreview && !game.richeseBetrayalPreview;
|
||||
}
|
||||
|
||||
export function revivalClaimText(claim: RevivalClaim): string {
|
||||
const counters = claim.counter === 'total' ? 'physical forces' :
|
||||
claim.counter === 'normal' ? 'normal forces' : 'elite forces';
|
||||
return `revive at least ${claim.minimum} ${counters} through ordinary force revival from now until your Revival opportunity ends this turn`;
|
||||
}
|
||||
|
||||
export function liveRevivalPromises(promises: readonly RevivalPromise[], player: string, turn: number): RevivalPromise[] {
|
||||
return promises.filter(promise => promise.player === player && promise.turn === turn &&
|
||||
!promise.fulfilled && !promise.released);
|
||||
}
|
||||
|
||||
function validateGroup(group: RevivalGroup): void {
|
||||
if (!group || typeof group !== 'object' || Array.isArray(group) ||
|
||||
!Number.isSafeInteger(group.amount) || group.amount < 0 || group.amount > 20 ||
|
||||
!Number.isSafeInteger(group.elite) || group.elite < 0 || group.elite > group.amount)
|
||||
throw new RevivalPromiseError('Retain a physical revival group of up to 20 forces with its actual elite count.');
|
||||
}
|
||||
|
||||
/** Each elite is one physical counter. Its battle strength is irrelevant. */
|
||||
export function revivalPromiseCount(claim: RevivalClaim, group: RevivalGroup): number {
|
||||
const parsed = parseRevivalClaim(claim);
|
||||
validateGroup(group);
|
||||
return parsed.counter === 'total' ? group.amount : parsed.counter === 'normal' ? group.amount - group.elite : group.elite;
|
||||
}
|
||||
|
||||
/** Provisional cumulative composition: only the caller's actual ordinary force
|
||||
* return commits progress. Special revivals, leaders and previous returns do
|
||||
* not call this function. A No cap applies across split requests as well. */
|
||||
export function recordPromisedRevival(promises: readonly RevivalPromise[], player: string, turn: number,
|
||||
group: RevivalGroup): RevivalPromise[] {
|
||||
validateGroup(group);
|
||||
const live = liveRevivalPromises(promises, player, turn);
|
||||
const progress = new Map<RevivalPromise, number>();
|
||||
for (const promise of live) {
|
||||
const next = promise.progress + revivalPromiseCount(promise, group);
|
||||
if (!Number.isSafeInteger(next) || next > 20)
|
||||
throw new RevivalPromiseError('The revival commitment exceeds its physical progress limit.');
|
||||
if (!promise.answer && next >= promise.minimum)
|
||||
throw new RevivalPromiseError('This ordinary revival conflicts with your cumulative Truthtrance No answer.');
|
||||
progress.set(promise, next);
|
||||
}
|
||||
return promises.map(promise => {
|
||||
const next = progress.get(promise);
|
||||
return next === undefined || next === promise.progress ? promise : {
|
||||
...promise, progress: next,
|
||||
...(promise.answer && next >= promise.minimum ? { fulfilled: true } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Test final additional ordinary returns, including none, against every live
|
||||
* answer together. This does not grant the quoted returns or mutate progress. */
|
||||
export function revivalPromisesSatisfied(promises: readonly RevivalPromise[], player: string, turn: number,
|
||||
group: RevivalGroup): boolean {
|
||||
validateGroup(group);
|
||||
return liveRevivalPromises(promises, player, turn).every(promise => {
|
||||
const next = promise.progress + revivalPromiseCount(promise, group);
|
||||
return Number.isSafeInteger(next) && next <= 20 && (next >= promise.minimum) === promise.answer;
|
||||
});
|
||||
}
|
||||
|
||||
const identifier = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0;
|
||||
const optionalBoolean = (value: unknown) => value === undefined || typeof value === 'boolean';
|
||||
|
||||
export function validateRevivalPromises(promises: readonly RevivalPromise[], players: readonly string[]): void {
|
||||
if (!Array.isArray(promises)) throw new RevivalPromiseError('The revival commitment record is invalid.');
|
||||
const events = new Set<string>();
|
||||
for (const promise of promises) {
|
||||
parseRevivalClaim(promise);
|
||||
if (!identifier(promise.event) || events.has(promise.event) ||
|
||||
!identifier(promise.player) || !identifier(promise.asker) ||
|
||||
!players.includes(promise.player) || !players.includes(promise.asker) || promise.player === promise.asker ||
|
||||
typeof promise.answer !== 'boolean' || !Number.isSafeInteger(promise.progress) ||
|
||||
promise.progress < 0 || promise.progress > 20 ||
|
||||
!optionalBoolean(promise.fulfilled) || !optionalBoolean(promise.released) ||
|
||||
(promise.fulfilled && promise.released) ||
|
||||
(!promise.answer && promise.progress >= promise.minimum) ||
|
||||
(promise.answer && !promise.released && !!promise.fulfilled !== (promise.progress >= promise.minimum)))
|
||||
throw new RevivalPromiseError('The revival commitment lost its event, participants or cumulative answer progress.');
|
||||
events.add(promise.event);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { parseRevivalClaim, revivalClaimText, revivalPromiseModeSupported, type RevivalClaim } from './revival-promises';
|
||||
import { parseMovementClaim, movementClaimText, movementPromiseTargetAvailable, MovementClaimError, type MovementClaim } from './movement-promises';
|
||||
import { shipmentAvailable } from './shipment-opportunity';
|
||||
import type { ShipmentClaim } from './shipment-promises';
|
||||
@@ -60,6 +61,7 @@ export type TruthFact =
|
||||
| { kind: 'spice'; compare: 'eq' | 'gte' | 'lte'; value: number }
|
||||
| { kind: 'and' | 'or'; terms: TruthFact[] };
|
||||
export type TruthQuestion =
|
||||
| ({ kind: 'revival'; target: string } & RevivalClaim)
|
||||
| ({ kind: 'bid'; target: string } & BidClaim)
|
||||
| { kind: 'traitorCall'; target: string; battle: string; territory: string }
|
||||
| ({ kind: 'movement'; target: string } & MovementClaim)
|
||||
@@ -212,6 +214,18 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
g.players.some((p) => p.id === v.target),
|
||||
'Ask one other player.',
|
||||
);
|
||||
if (v.kind === 'revival') {
|
||||
check(Object.keys(v).sort().join(',') === 'counter,kind,minimum,target,turn',
|
||||
'Supply the current Revival turn, physical counter type, minimum and respondent.');
|
||||
const claim = parseRevivalClaim(v);
|
||||
check(revivalPromiseModeSupported(g) && g.status === 'playing' && g.phase === 4 &&
|
||||
claim.turn === g.turn && !g.ready.includes(v.target as string) &&
|
||||
(!g.revivalOrder || g.revivalOrder.remaining.includes(v.target as string)) &&
|
||||
!g.pendingRevival && !g.response && !g.decision && !g.phaseOpening &&
|
||||
!g.pendingKarama && !g.pendingTreacheryDiscard,
|
||||
'Choose a remaining native Revival opportunity in a supported table and finish pending decisions.');
|
||||
return { kind: 'revival', target: v.target, ...claim };
|
||||
}
|
||||
if (v.kind === 'bid') {
|
||||
check(Object.keys(v).sort().join(',') === 'kind,lot,minimum,target,turn',
|
||||
'Supply the current ordinary lot, turn, bid minimum and respondent.');
|
||||
@@ -447,6 +461,7 @@ export function truthQuestionText(
|
||||
locationName: (id: string) => string,
|
||||
): string {
|
||||
if (q.kind === 'freeform') return q.text;
|
||||
if (q.kind === 'revival') return `Will you ${revivalClaimText(q)}?`;
|
||||
if (q.kind === 'bid') return `Will you ${bidClaimText(q)}?`;
|
||||
if (q.kind === 'traitorCall') return `In this revealed battle in ${locationName(q.territory)}, will you declare the opposing leader a Traitor?`;
|
||||
if (q.kind === 'movement') return `Will you ${movementClaimText(q)} before your Shipment and Movement turn ends?`;
|
||||
@@ -493,6 +508,8 @@ export function resolveTruthAction(
|
||||
) => void;
|
||||
movementAnswers?: (g: Game, p: Player, claim: MovementClaim) => TruthAnswer[];
|
||||
bindMovement?: (g: Game, p: Player, claim: MovementClaim, answer: boolean, asker: string) => void;
|
||||
revivalAnswers?: (g: Game, p: Player, claim: RevivalClaim) => TruthAnswer[];
|
||||
bindRevival?: (g: Game, p: Player, claim: RevivalClaim, answer: boolean, asker: string) => void;
|
||||
bidAnswers?: (g: Game, p: Player, claim: BidClaim) => TruthAnswer[];
|
||||
bindBid?: (g: Game, p: Player, claim: BidClaim, answer: boolean, asker: string) => void;
|
||||
traitorAnswers?: (g: Game, p: Player) => TruthAnswer[];
|
||||
@@ -613,6 +630,9 @@ export function resolveTruthAction(
|
||||
'Waiting for the Truthtrance holder to ask a question.',
|
||||
);
|
||||
w.question = parseQuestion(g, p.id, a.question);
|
||||
if (w.question.kind === 'revival')
|
||||
check(effects.revivalAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
|
||||
'Choose a respondent with a remaining native Revival opportunity.');
|
||||
if (w.question.kind === 'bid')
|
||||
check(effects.bidAnswers?.(g, g.players.find(other => other.id === w.question!.target)!, w.question).length,
|
||||
'Choose a bidder with a remaining ordinary opportunity in this lot.');
|
||||
@@ -666,6 +686,11 @@ export function resolveTruthAction(
|
||||
'Choose an answer consistent with legal ground movement and earlier promises.');
|
||||
if (a.answer !== 'unknown') effects.bindMovement(g, p, q, a.answer === 'yes', current.player);
|
||||
}
|
||||
if (q.kind === 'revival') {
|
||||
check(effects.revivalAnswers && effects.bindRevival && effects.revivalAnswers(g, p, q).includes(a.answer),
|
||||
'Choose an answer consistent with native force revivals and earlier answers.');
|
||||
if (a.answer !== 'unknown') effects.bindRevival(g, p, q, a.answer === 'yes', current.player);
|
||||
}
|
||||
if (q.kind === 'bid') {
|
||||
check(effects.bidAnswers && effects.bindBid && effects.bidAnswers(g, p, q).includes(a.answer),
|
||||
'Choose an answer consistent with this lot, your legal bids and earlier answers.');
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { TERRITORIES } from '../game/board';
|
||||
import { forceRevivalQuote } from '../game/revival';
|
||||
import type { RevivalClaim } from '../game/revival-promises';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
import { actTruthBid, truthBidInventory, truthBidPlayer } from './fixture-truth-bid';
|
||||
|
||||
export type TruthRevivalStep = { actor: string; action: Action };
|
||||
export type TruthRevivalTransition = { before: Game; steps: TruthRevivalStep[]; after: Game };
|
||||
export const actTruthRevival = actTruthBid;
|
||||
export const truthRevivalInventory = truthBidInventory;
|
||||
export const truthRevivalPlayer = truthBidPlayer;
|
||||
export const truthRevivalClean = (game: Game) => !game.phaseOpening && !game.response && !game.decision &&
|
||||
!game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingRevival;
|
||||
export function nextTruthRevival(game: Game): TruthRevivalStep | null {
|
||||
if (game.truthtrance) return null;
|
||||
if (!game.response && !game.phaseOpening && game.decision?.kind === 'revivalStop')
|
||||
return { actor: game.decision.player, action: { type: 'decision', decline: true } };
|
||||
if (game.phase === 4 && truthRevivalClean(game)) {
|
||||
const actors = game.revivalOrder ? game.revivalOrder.remaining.slice(0, 1) : game.players.filter(player => !game.ready.includes(player.id)).map(player => player.id);
|
||||
for (const actor of actors) {
|
||||
const guide = viewGame(game, actor).revivalPromiseGuide;
|
||||
if (guide) return { actor, action: guide.action };
|
||||
}
|
||||
}
|
||||
return nextStrongholdFactionsNativeStep(game);
|
||||
}
|
||||
export function advanceTruthRevival(game: Game, until: (state: Game) => boolean, actions: TruthRevivalStep[] = []): Game {
|
||||
for (let count = 0; count < 1200; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The original Revival source ended before its owned control');
|
||||
const next = nextTruthRevival(game); assert.ok(next, 'The original Truthtrance answer belongs to its caller');
|
||||
game = actTruthRevival(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The original Revival source did not reach its finite boundary');
|
||||
}
|
||||
export function settleTruthRevival(game: Game, actions: TruthRevivalStep[] = []): Game {
|
||||
return advanceTruthRevival(game, truthRevivalClean, actions);
|
||||
}
|
||||
export function finishTruthRevival(game: Game, actions: TruthRevivalStep[] = []): Game {
|
||||
const turn = game.turn;
|
||||
return advanceTruthRevival(game, state => state.turn === turn && state.phase === 5 && truthRevivalClean(state), actions);
|
||||
}
|
||||
|
||||
/** Genuine public H/A/E base33; optional native Tleilaxu selects original Ix47
|
||||
* without optional modules. H receives both original Truthtrances. E really
|
||||
* pays6 to ship3 into next-Storm sector2, including one Sardaukar in Advanced.
|
||||
* The real next Storm destroys those counters before original turn2 Revival.
|
||||
* Only unopened deal/Spice positions and scalar lotteries are selected; no
|
||||
* played hand, purse, army, Tanks, clock, question or promise is installed. */
|
||||
export function createTruthRevivalFixture(options: {
|
||||
advanced?: boolean; window?: 'active' | 'waiting'; targetGhola?: boolean; stopper?: boolean;
|
||||
} = {}) {
|
||||
const advanced = options.advanced ?? false, window = options.window ?? 'active', stopper = options.stopper ?? false;
|
||||
assert.ok(!stopper || advanced, 'The native Tleilaxu special Karama witness uses its actual Advanced power');
|
||||
const asker = 'harkonnen', opponent = 'atreides', target = 'emperor';
|
||||
let game = createGame('TRUTHREVIVALSOURCE', newPlayer(asker, 'Harkonnen', 'harkonnen'), advanced, stopper ? ['ix'] : []);
|
||||
for (const faction of [opponent, target, ...(stopper ? ['tleilaxu' as const] : [])] as const)
|
||||
joinGame(game, newPlayer(faction, faction, faction));
|
||||
const initial = structuredClone(game), actions: TruthRevivalStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actTruthRevival(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceTruthRevival(game, until, actions); };
|
||||
const transition = (run: () => void): TruthRevivalTransition => {
|
||||
const before = structuredClone(game), from = actions.length; run();
|
||||
return { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
|
||||
};
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
act(asker, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
|
||||
const setup = structuredClone(game); assert.equal(game.status, 'setup');
|
||||
assert.ok(game.players.every(player => !player.hand.length));
|
||||
const originalCards = truthRevivalInventory(game); assert.equal(originalCards.length, stopper ? 47 : 33);
|
||||
const truths = game.deck.filter(card => card.effect === 'truthtrance'); assert.equal(truths.length, 2);
|
||||
const filler = game.deck.find(card => card.name === 'Baliset');
|
||||
const targetCard = game.deck.find(card => options.targetGhola ? card.effect === 'ghola' : card.kind === 'shield');
|
||||
const karama = stopper ? game.deck.find(card => card.effect === 'karama') : null;
|
||||
assert.ok(filler && targetCard && (!stopper || karama));
|
||||
const deal = [truths[0].id, truths[1].id, filler.id, targetCard.id, ...(karama ? [karama.id] : [])];
|
||||
const dealOrder = deal.map((id, position) => {
|
||||
const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { position, from, card: id };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game);
|
||||
reach(state => state.status === 'playing');
|
||||
const afterSetup = structuredClone(game);
|
||||
assert.deepEqual(truthRevivalPlayer(game, asker).hand.map(card => card.id), truths.map(card => card.id));
|
||||
assert.ok(truthRevivalPlayer(game, target).hand.some(card => card.id === targetCard.id));
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const spiceOrder = [0, 1, 2, 3].map(position => {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery && card.sector >= 7);
|
||||
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card);
|
||||
return { position, from, card: structuredClone(card) };
|
||||
});
|
||||
reach(state => state.turn === 1 && state.phase === 5 && state.active === target && truthRevivalClean(state));
|
||||
assert.equal(game.storm, 1);
|
||||
const sector = 2, destination = TERRITORIES.find(place => place.type === 'sand' && place.sectors.includes(sector) &&
|
||||
game.players.every(player => !Object.keys(player.forces).some(key => key.startsWith(`${place.id}:`))));
|
||||
assert.ok(destination);
|
||||
const location = `${destination.id}:${sector}`, elite = advanced ? 1 : 0;
|
||||
const shipmentAction: Action = { type: 'ship', territory: destination.id, sector, amount: 3, elite, allyPayment: 0 };
|
||||
const shipment = transition(() => { act(target, shipmentAction); reach(truthRevivalClean); });
|
||||
assert.equal(truthRevivalPlayer(game, target).spice, 4); assert.equal(truthRevivalPlayer(game, target).forces[location], 3);
|
||||
assert.equal(truthRevivalPlayer(game, target).elites?.forces[location] ?? 0, elite);
|
||||
const continuation = transition(() => {
|
||||
reach(state => state.turn === 2 && state.phase === 0 && truthRevivalClean(state) && state.stormPending !== null);
|
||||
});
|
||||
const storm = transition(() => { reach(state => state.turn === 2 && state.phase === 1 && truthRevivalClean(state)); });
|
||||
assert.equal(truthRevivalPlayer(game, target).forces[location], undefined);
|
||||
assert.equal(truthRevivalPlayer(game, target).tanks, 3);
|
||||
assert.equal(truthRevivalPlayer(game, target).elites?.tanks ?? 0, elite);
|
||||
const arrival = transition(() => { reach(state => state.turn === 2 && state.phase === 4 && truthRevivalClean(state)); });
|
||||
const opened = structuredClone(game);
|
||||
if (window === 'active' && game.revivalOrder) {
|
||||
assert.ok(game.revivalOrder.remaining.includes(target));
|
||||
reach(state => state.phase === 4 && truthRevivalClean(state) && state.revivalOrder?.remaining[0] === target);
|
||||
}
|
||||
const source = truthRevivalPlayer(game, target), quote = forceRevivalQuote(game, source, 3, elite);
|
||||
assert.equal(source.spice, 4); assert.equal(source.reserves, 17); assert.equal(source.revived, 0);
|
||||
assert.equal(source.freeForcesRevived ?? 0, 0); assert.equal(quote.free, 1); assert.equal(quote.cost, 4);
|
||||
assert.deepEqual(truthRevivalInventory(game), originalCards);
|
||||
const question = { kind: 'revival' as const, target, turn: game.turn, counter: 'total' as const, minimum: 3 };
|
||||
const special: Action | null = karama ? { type: 'card', card: karama.id, mode: 'special', target } : null;
|
||||
return { game, sourceGame: structuredClone(game), initial, lobby, setup, beforeStartingDeal, afterSetup,
|
||||
advanced, window, stopper: stopper ? 'tleilaxu' : null, asker, opponent, target, actor: asker,
|
||||
truths: truths.map(card => card.id), truth: truths[0].id, card: truths[0].id,
|
||||
ghola: options.targetGhola ? targetCard.id : null, karama: karama?.id ?? null, special,
|
||||
destination: destination.id, sector, location, elite, shipmentAction, shipment, continuation, storm, arrival, opened,
|
||||
quote, question, declarationAction: { type: 'card', card: truths[0].id } as Action,
|
||||
questionAction: { type: 'truthAsk', question } as Action, revivalAction: { type: 'revive', amount: 3, elite } as Action,
|
||||
originalCards, originalInventory: originalCards, originalSpice, deal, dealOrder, spiceOrder,
|
||||
actions, sourceActions: structuredClone(actions), sourceActionCount: actions.length,
|
||||
staging: [] as string[], sourceDisclosure: 'Original public H/A/E basic33 (or Advanced native Tleilaxu/Ix47) selects only unopened deals/ordinary Spice and scalar lotteries. Emperor actually ships3 for6, loses3 to the next native Storm and reaches original turn2 Revival with4 spice; Advanced casualties include1 Sardaukar. All ordinary returns, Truth declarations/answers and optional original Ghola/Tleilaxu Karama are caller actions, never installed state.' };
|
||||
}
|
||||
export type TruthRevivalFixture = ReturnType<typeof createTruthRevivalFixture>;
|
||||
|
||||
export function declareTruthRevival(f: TruthRevivalFixture, state = f.game, options: { copies?: 1 | 2 } = {}) {
|
||||
let game = state; const before = structuredClone(game), actions: TruthRevivalStep[] = [];
|
||||
const declaration: Action = { type: 'card', card: f.truths[0], ...(options.copies === 2 ? { cards: [f.truths[1]] } : {}) };
|
||||
game = actTruthRevival(game, f.asker, declaration, actions);
|
||||
for (let count = 0; game.truthtrance?.stage === 'priority' && count < game.players.length; count++) {
|
||||
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor);
|
||||
game = actTruthRevival(game, actor.id, { type: 'truthPass' }, actions);
|
||||
}
|
||||
assert.equal(game.truthtrance?.stage, 'ask');
|
||||
return { game, before, declaration, actions, actionCount: actions.length };
|
||||
}
|
||||
export function askTruthRevival(game: Game, asker: string, target: string, minimum = 3,
|
||||
counter: RevivalClaim['counter'] = 'total', actions: TruthRevivalStep[] = []): Game {
|
||||
return actTruthRevival(game, asker, { type: 'truthAsk', question: { kind: 'revival', target, turn: game.turn, counter, minimum } }, actions);
|
||||
}
|
||||
export function answerTruthRevival(game: Game, target: string, answer: 'yes' | 'no', actions: TruthRevivalStep[] = []): Game {
|
||||
return actTruthRevival(game, target, { type: 'truthAnswer', answer }, actions);
|
||||
}
|
||||
export function bindTruthRevival(f: TruthRevivalFixture, answer: 'yes' | 'no' = 'yes', minimum = 3,
|
||||
counter: RevivalClaim['counter'] = 'total', state = f.game) {
|
||||
const declared = declareTruthRevival(f, state), actions = [...declared.actions];
|
||||
const asked = askTruthRevival(declared.game, f.asker, f.target, minimum, counter, actions);
|
||||
const game = answerTruthRevival(asked, f.target, answer, actions);
|
||||
return { game, declared, asked, actions, actionCount: actions.length };
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, normalizeAutomaticGame, viewGame, type Game, type Player } from '../game/engine';
|
||||
import { createNexusFremenFreeQuotaFixture } from './fixture-nexus-fremen-free-quota';
|
||||
import { awaitSpiceBankerIncomeRevivalActor } from './fixture-spice-banker-income';
|
||||
|
||||
function pools(player: Player) {
|
||||
return { reserves: player.reserves, tanks: player.tanks,
|
||||
@@ -13,9 +14,10 @@ function pools(player: Player) {
|
||||
for (const advanced of [false, true]) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} saved exact-three physical receipt adopts its exhausted quota without replaying the actual return`, () => {
|
||||
const fixture = createNexusFremenFreeQuotaFixture({ advanced });
|
||||
const before = fixture.game.players.find(player => player.id === fixture.owner)!;
|
||||
const offer = viewGame(fixture.game, fixture.owner).nexusFremenRevival!;
|
||||
const granted = applyAction(fixture.game, fixture.owner,
|
||||
const source = awaitSpiceBankerIncomeRevivalActor(fixture.game, fixture.owner);
|
||||
const before = source.players.find(player => player.id === fixture.owner)!;
|
||||
const offer = viewGame(source, fixture.owner).nexusFremenRevival!;
|
||||
const granted = applyAction(source, fixture.owner,
|
||||
{ type: 'nexusFremenRevival', event: offer.event });
|
||||
const returned = applyAction(granted, fixture.owner, { type: 'revive', amount: 3, elite: 0 });
|
||||
const after = returned.players.find(player => player.id === fixture.owner)!;
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
RevivalPromiseError,
|
||||
liveRevivalPromises,
|
||||
parseRevivalClaim,
|
||||
recordPromisedRevival,
|
||||
revivalClaimText,
|
||||
revivalPromiseCount,
|
||||
revivalPromiseModeSupported,
|
||||
revivalPromisesSatisfied,
|
||||
validateRevivalPromises,
|
||||
type RevivalClaim,
|
||||
type RevivalPromise,
|
||||
} from '../game/revival-promises';
|
||||
|
||||
const seats = ['respondent', 'asker', 'rival'] as const;
|
||||
function promise(event: string, minimum: number, extra: Partial<RevivalPromise> = {}): RevivalPromise {
|
||||
return { event, turn: 2, counter: 'total', minimum, player: 'respondent', asker: 'asker',
|
||||
answer: true, progress: 0, ...extra };
|
||||
}
|
||||
|
||||
void test('revival claims retain only the current-turn physical counter and positive bounded threshold', () => {
|
||||
const input = { turn: 2, counter: 'normal', minimum: 4, privateTanks: 12, priorReturns: 3 };
|
||||
assert.deepEqual(parseRevivalClaim(input), { turn: 2, counter: 'normal', minimum: 4 });
|
||||
for (const counter of ['total', 'normal', 'elite'] as const) {
|
||||
assert.deepEqual(parseRevivalClaim({ turn: 1, counter, minimum: 1 }), { turn: 1, counter, minimum: 1 });
|
||||
assert.deepEqual(parseRevivalClaim({ turn: Number.MAX_SAFE_INTEGER, counter, minimum: 20 }),
|
||||
{ turn: Number.MAX_SAFE_INTEGER, counter, minimum: 20 });
|
||||
}
|
||||
const valid = { turn: 2, counter: 'total', minimum: 4 };
|
||||
for (const turn of [0, -1, 1.5, Number.NaN, Infinity, Number.MAX_SAFE_INTEGER + 1, '2', null, undefined])
|
||||
assert.throws(() => parseRevivalClaim({ ...valid, turn }), RevivalPromiseError);
|
||||
for (const minimum of [0, -1, 1.5, 21, Number.NaN, Infinity, Number.MAX_SAFE_INTEGER, '4', null, undefined])
|
||||
assert.throws(() => parseRevivalClaim({ ...valid, minimum }), RevivalPromiseError);
|
||||
for (const counter of ['forces', 'sardaukar', '', 1, null, undefined])
|
||||
assert.throws(() => parseRevivalClaim({ ...valid, counter }), RevivalPromiseError);
|
||||
for (const value of [null, undefined, [], 'four', 4]) assert.throws(() => parseRevivalClaim(value), RevivalPromiseError);
|
||||
assert.match(revivalClaimText(parseRevivalClaim(input)), /at least 4 normal forces/);
|
||||
assert.match(revivalClaimText(parseRevivalClaim(input)), /ordinary force revival from now/);
|
||||
assert.match(revivalClaimText(parseRevivalClaim(input)), /your Revival opportunity ends this turn/);
|
||||
});
|
||||
|
||||
void test('revival promise scope preserves bare registered native Basic and Advanced selections and optional-module fences', () => {
|
||||
const classic = { advanced: false, expansions: [], players: [{ faction: 'atreides' }, { faction: 'emperor' }] };
|
||||
const native = { advanced: true, expansions: ['ix', 'choam', 'ecaz'],
|
||||
players: ['ixians', 'tleilaxu', 'choam', 'richese', 'ecaz', 'moritani'].map(faction => ({ faction })) };
|
||||
for (const game of [classic, { ...classic, advanced: true }, native, { ...native, advanced: false },
|
||||
{ ...classic, expansions: ['ix', 'choam', 'ecaz'] },
|
||||
{ ...classic, advanced: true, expansions: ['ecaz'], players: [{ faction: 'moritani' }, { faction: 'harkonnen' }] },
|
||||
]) assert.equal(revivalPromiseModeSupported(game), true);
|
||||
for (const option of ['homeworlds', 'nexusCards', 'leaderSkills', 'techTokens', 'strongholdCards',
|
||||
'discoveryEnabled', 'discoveries', 'discoveryStash', 'greatMaker', 'ecazTreachery',
|
||||
'semutaPreview', 'kullPreview', 'nexusKullPreview', 'richeseBetrayalPreview'] as const)
|
||||
assert.equal(revivalPromiseModeSupported({ ...native, [option]: true }), false, option);
|
||||
for (const game of [
|
||||
{ ...native, expansions: ['ix', 'choam'] }, { ...native, expansions: ['ix', 'ix', 'ecaz'] },
|
||||
{ ...classic, expansions: ['unknown'] }, { ...classic, players: [{ faction: 'atreides' }, { faction: 'unknown' }] },
|
||||
{ ...classic, players: [{ faction: 'atreides' }] }, { ...classic, players: [...native.players, { faction: 'atreides' }] },
|
||||
{ ...classic, advanced: 'basic' as unknown as boolean },
|
||||
]) assert.equal(revivalPromiseModeSupported(game), false);
|
||||
});
|
||||
|
||||
void test('typed revival counts physical elites once and separates total, normal and elite quantities', () => {
|
||||
const group = { amount: 3, elite: 2 };
|
||||
const expected = { total: 3, normal: 1, elite: 2 };
|
||||
for (const counter of ['total', 'normal', 'elite'] as const) {
|
||||
const claim: RevivalClaim = { turn: 2, counter, minimum: 1 };
|
||||
assert.equal(revivalPromiseCount(claim, group), expected[counter]);
|
||||
assert.equal(revivalPromiseCount(claim, { amount: 0, elite: 0 }), 0);
|
||||
}
|
||||
assert.equal(revivalPromiseCount({ turn: 2, counter: 'normal', minimum: 20 }, { amount: 20, elite: 0 }), 20);
|
||||
assert.equal(revivalPromiseCount({ turn: 2, counter: 'total', minimum: 2 }, { amount: 2, elite: 2 }), 2);
|
||||
for (const group of [{ amount: -1, elite: 0 }, { amount: 21, elite: 0 }, { amount: 1.5, elite: 0 },
|
||||
{ amount: Infinity, elite: 0 }, { amount: 2, elite: -1 }, { amount: 2, elite: 3 },
|
||||
{ amount: 2, elite: 0.5 }, { amount: 2, elite: Number.NaN }]) {
|
||||
assert.throws(() => revivalPromiseCount({ turn: 2, counter: 'total', minimum: 1 }, group), RevivalPromiseError);
|
||||
assert.throws(() => recordPromisedRevival([], 'respondent', 2, group), RevivalPromiseError);
|
||||
assert.throws(() => revivalPromisesSatisfied([], 'respondent', 2, group), RevivalPromiseError);
|
||||
}
|
||||
});
|
||||
|
||||
void test('future ordinary requests accumulate after each answer while prior completed returns do not satisfy a new promise', () => {
|
||||
const original = [promise('first-answer', 5)];
|
||||
const first = recordPromisedRevival(original, 'respondent', 2, { amount: 2, elite: 1 });
|
||||
assert.equal(first[0].progress, 2); assert.equal(first[0].fulfilled, undefined);
|
||||
const second = recordPromisedRevival(first, 'respondent', 2, { amount: 3, elite: 0 });
|
||||
assert.equal(second[0].progress, 5); assert.equal(second[0].fulfilled, true);
|
||||
const later = [...second, promise('later-answer', 2)];
|
||||
assert.equal(revivalPromisesSatisfied(later, 'respondent', 2, { amount: 0, elite: 0 }), false,
|
||||
'Five earlier returns are outside the new answer’s future progress');
|
||||
const third = recordPromisedRevival(later, 'respondent', 2, { amount: 1, elite: 0 });
|
||||
assert.equal(third[0], second[0]); assert.equal(third[1].progress, 1); assert.equal(third[1].fulfilled, undefined);
|
||||
const fourth = recordPromisedRevival(third, 'respondent', 2, { amount: 1, elite: 0 });
|
||||
assert.equal(fourth[1].progress, 2); assert.equal(fourth[1].fulfilled, true);
|
||||
assert.equal(original[0].progress, 0); assert.equal(first[0].progress, 2);
|
||||
});
|
||||
|
||||
void test('one hypothetical completion must satisfy total, normal, elite and No answers together', () => {
|
||||
const records = [promise('total-four', 4), promise('normal-three', 3, { counter: 'normal' }),
|
||||
promise('elite-one', 1, { counter: 'elite' }), promise('not-two-elites', 2, { counter: 'elite', answer: false })];
|
||||
assert.equal(revivalPromisesSatisfied(records, 'respondent', 2, { amount: 4, elite: 1 }), true);
|
||||
assert.equal(revivalPromisesSatisfied(records, 'respondent', 2, { amount: 4, elite: 2 }), false,
|
||||
'Four total cannot compensate for too few normals or a breached elite cap');
|
||||
assert.equal(revivalPromisesSatisfied(records, 'respondent', 2, { amount: 4, elite: 0 }), false);
|
||||
const split = recordPromisedRevival(records, 'respondent', 2, { amount: 2, elite: 1 });
|
||||
assert.deepEqual(split.map(record => record.progress), [2, 1, 1, 1]);
|
||||
assert.equal(revivalPromisesSatisfied(split, 'respondent', 2, { amount: 2, elite: 0 }), true);
|
||||
const completed = recordPromisedRevival(split, 'respondent', 2, { amount: 2, elite: 0 });
|
||||
assert.deepEqual(completed.map(record => !!record.fulfilled), [true, true, true, false]);
|
||||
assert.equal(completed[3].progress, 1);
|
||||
const conflict = [promise('two-elites', 2, { counter: 'elite' }),
|
||||
promise('below-two-elites', 2, { counter: 'elite', answer: false })];
|
||||
assert.equal(revivalPromisesSatisfied(conflict, 'respondent', 2, { amount: 2, elite: 2 }), false);
|
||||
assert.equal(revivalPromisesSatisfied(conflict, 'respondent', 2, { amount: 2, elite: 1 }), false);
|
||||
});
|
||||
|
||||
void test('No forbids crossing a cumulative cap through smaller split requests and rejection leaves every record unchanged', () => {
|
||||
const initial = [promise('need-three-normal', 3, { counter: 'normal' }), promise('below-four-total', 4, { answer: false })];
|
||||
const first = recordPromisedRevival(initial, 'respondent', 2, { amount: 2, elite: 1 });
|
||||
const second = recordPromisedRevival(first, 'respondent', 2, { amount: 1, elite: 0 });
|
||||
assert.deepEqual(second.map(record => record.progress), [2, 3]);
|
||||
const before = structuredClone(second); second.forEach(Object.freeze); Object.freeze(second);
|
||||
assert.throws(() => recordPromisedRevival(second, 'respondent', 2, { amount: 1, elite: 0 }), RevivalPromiseError);
|
||||
assert.deepEqual(second, before, 'A disallowed fourth total return cannot partly fulfill the normal Yes');
|
||||
assert.equal(revivalPromisesSatisfied(second, 'respondent', 2, { amount: 1, elite: 0 }), false);
|
||||
assert.equal(initial[0].progress, 0); assert.equal(initial[1].progress, 0);
|
||||
});
|
||||
|
||||
void test('fulfilled and released answers retain their exact records while an unmatched typed counter is a no-op', () => {
|
||||
const fulfilled = promise('fulfilled', 2, { progress: 2, fulfilled: true });
|
||||
const released = promise('released', 2, { progress: 1, released: true });
|
||||
const elite = promise('still-needs-elite', 1, { counter: 'elite' });
|
||||
const no = promise('continuing-no', 4, { answer: false });
|
||||
const records = Object.freeze([fulfilled, released, elite, no]); records.forEach(Object.freeze);
|
||||
const next = recordPromisedRevival(records, 'respondent', 2, { amount: 2, elite: 0 });
|
||||
assert.equal(next[0], fulfilled); assert.equal(next[1], released); assert.equal(next[2], elite);
|
||||
assert.notEqual(next[3], no); assert.equal(next[3].progress, 2); assert.equal(next[3].fulfilled, undefined);
|
||||
assert.deepEqual(liveRevivalPromises(next, 'respondent', 2), [elite, next[3]]);
|
||||
});
|
||||
|
||||
void test('current turn and respondent isolate progress while different askers retain jointly binding answers', () => {
|
||||
const own = promise('own', 3), secondAsker = promise('rival-asker', 5, { asker: 'rival', answer: false });
|
||||
const otherTurn = promise('old-turn', 1, { turn: 1 });
|
||||
const otherPlayer = promise('other-player', 1, { player: 'rival' });
|
||||
const records = [own, secondAsker, otherTurn, otherPlayer];
|
||||
assert.deepEqual(liveRevivalPromises(records, 'respondent', 2), [own, secondAsker]);
|
||||
assert.equal(revivalPromisesSatisfied(records, 'respondent', 2, { amount: 3, elite: 0 }), true);
|
||||
const next = recordPromisedRevival(records, 'respondent', 2, { amount: 3, elite: 0 });
|
||||
assert.equal(next[0].fulfilled, true); assert.equal(next[1].progress, 3);
|
||||
assert.equal(next[2], otherTurn); assert.equal(next[3], otherPlayer);
|
||||
const unrelated = recordPromisedRevival(records, 'absent', 2, { amount: 1, elite: 0 });
|
||||
records.forEach((record, index) => assert.equal(unrelated[index], record));
|
||||
});
|
||||
|
||||
void test('zero final additions distinguish outstanding Yes from satisfied No and progress cannot exceed twenty physical returns', () => {
|
||||
const no = [promise('below-two', 2, { answer: false, progress: 1 })];
|
||||
assert.equal(revivalPromisesSatisfied(no, 'respondent', 2, { amount: 0, elite: 0 }), true);
|
||||
const yes = [promise('one-remaining', 20, { progress: 19 })];
|
||||
assert.equal(revivalPromisesSatisfied(yes, 'respondent', 2, { amount: 0, elite: 0 }), false);
|
||||
const zero = recordPromisedRevival(yes, 'respondent', 2, { amount: 0, elite: 0 });
|
||||
assert.equal(zero[0], yes[0]);
|
||||
assert.equal(revivalPromisesSatisfied(yes, 'respondent', 2, { amount: 1, elite: 0 }), true);
|
||||
const completed = recordPromisedRevival(yes, 'respondent', 2, { amount: 1, elite: 0 });
|
||||
assert.equal(completed[0].progress, 20); assert.equal(completed[0].fulfilled, true);
|
||||
assert.equal(revivalPromisesSatisfied(yes, 'respondent', 2, { amount: 2, elite: 0 }), false);
|
||||
assert.throws(() => recordPromisedRevival(yes, 'respondent', 2, { amount: 2, elite: 0 }), RevivalPromiseError);
|
||||
assert.equal(revivalPromisesSatisfied(completed, 'respondent', 2, { amount: 0, elite: 0 }), true);
|
||||
assert.equal(revivalPromisesSatisfied([], 'respondent', 2, { amount: 0, elite: 0 }), true);
|
||||
});
|
||||
|
||||
void test('record validation binds original events, participants and consistent cumulative terminal states', () => {
|
||||
const valid = [promise('live-yes', 3, { progress: 2 }),
|
||||
promise('fulfilled-yes', 3, { progress: 3, fulfilled: true }),
|
||||
promise('released-yes', 3, { progress: 1, released: true }),
|
||||
promise('live-no', 3, { progress: 2, answer: false }),
|
||||
promise('completed-no', 3, { progress: 2, answer: false, fulfilled: true }),
|
||||
promise('released-no', 3, { progress: 2, answer: false, released: true })];
|
||||
const before = structuredClone(valid);
|
||||
assert.doesNotThrow(() => validateRevivalPromises(valid, seats)); assert.deepEqual(valid, before);
|
||||
assert.doesNotThrow(() => validateRevivalPromises(JSON.parse(JSON.stringify(valid)) as RevivalPromise[], seats));
|
||||
assert.doesNotThrow(() => validateRevivalPromises([], seats));
|
||||
const base = promise('event', 3);
|
||||
const invalid: unknown[] = [null, {}, [null], [base, { ...base }],
|
||||
...[{ event: '' }, { event: ' ' }, { player: 'outsider' }, { asker: 'outsider' }, { asker: 'respondent' },
|
||||
{ answer: 'yes' }, { progress: -1 }, { progress: 21 }, { progress: 1.5 }, { progress: Number.NaN },
|
||||
{ progress: Number.MAX_SAFE_INTEGER + 1 }, { fulfilled: 1 }, { released: 'true' },
|
||||
{ fulfilled: true, progress: 2 }, { fulfilled: false, progress: 3 }, { progress: 3 },
|
||||
{ fulfilled: true, released: true, progress: 3 }, { answer: false, progress: 3 },
|
||||
{ answer: false, progress: 3, fulfilled: true }, { answer: false, progress: 3, released: true },
|
||||
{ turn: 0 }, { minimum: 21 }, { counter: 'sardaukar' }].map(extra => [{ ...base, ...extra }])];
|
||||
for (const records of invalid)
|
||||
assert.throws(() => validateRevivalPromises(records as RevivalPromise[], seats), RevivalPromiseError);
|
||||
assert.deepEqual(valid, before);
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { createElement, isValidElement, type ReactElement, type ReactNode } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { RevivalClaimFields, revivalClaimInputError } from '../components/revival-claim-fields';
|
||||
import { RevivalPromises, RevivalPromiseGuideButton } from '../components/revival-promises';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
|
||||
import type { RevivalClaim } from '../game/revival-promises';
|
||||
import { createTechTokens } from '../game/tech-tokens';
|
||||
import { createTruthRevivalFixture, bindTruthRevival, actTruthRevival, settleTruthRevival } from './fixture-truth-revival';
|
||||
|
||||
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 { Truthtrance } = await import('../components/truthtrance');
|
||||
aliases.deregister();
|
||||
|
||||
type ElementProps = { children?: ReactNode; [key: string]: unknown };
|
||||
type Element = ReactElement<ElementProps>;
|
||||
function elements(node: ReactNode): Element[] {
|
||||
if (Array.isArray(node)) return node.flatMap(elements);
|
||||
if (!isValidElement<ElementProps>(node)) return [];
|
||||
return [node, ...elements(node.props.children)];
|
||||
}
|
||||
function field(props: Parameters<typeof RevivalClaimFields>[0], id: string) {
|
||||
const found = elements(RevivalClaimFields(props)).find(element => element.props.id === id && typeof element.props.onChange === 'function');
|
||||
assert.ok(found); return found;
|
||||
}
|
||||
function minimum(props: Parameters<typeof RevivalClaimFields>[0], value: number) {
|
||||
(field(props, 'truth-revival-minimum').props.onChange as (event: { currentTarget: { valueAsNumber: number } }) => void)(
|
||||
{ currentTarget: { valueAsNumber: value } });
|
||||
}
|
||||
function counter(props: Parameters<typeof RevivalClaimFields>[0], value: string) {
|
||||
(field(props, 'truth-revival-counter').props.onChange as (event: { currentTarget: { value: string } }) => void)(
|
||||
{ currentTarget: { value } });
|
||||
}
|
||||
function guide(props: Parameters<typeof RevivalPromiseGuideButton>[0]) {
|
||||
const button = RevivalPromiseGuideButton(props); assert.ok(button); return button;
|
||||
}
|
||||
const press = (button: ReturnType<typeof guide>) => button.props.onClick();
|
||||
const markup = (game: GameView, busy = false) => renderToStaticMarkup(createElement(RevivalPromises, { game, busy, send() {} }));
|
||||
|
||||
/** Controlled entitled projections exercise UI guards only. The source-earned
|
||||
* producer below supplies actual ordinary returns and cumulative progress. */
|
||||
function projected(action: 'revive' | 'ready' = 'revive', advanced = false): GameView {
|
||||
const source = createGame('TRUTHREVIVALCONTROLS', newPlayer('p', 'Respondent', 'emperor'), advanced);
|
||||
joinGame(source, newPlayer('a', 'Asker', 'atreides'));
|
||||
const game = viewGame(source, 'p');
|
||||
game.status = 'playing'; game.phase = 4; game.turn = 2; game.ready = [];
|
||||
game.revivalOrder = advanced ? { version: 1, turn: 2, original: ['p', 'a'], remaining: ['p', 'a'], completed: [], started: false } : null;
|
||||
game.active = advanced ? 'p' : null;
|
||||
game.revivalPromises = [{ event: 'controlled-revival-event', player: 'p', asker: 'a', turn: 2,
|
||||
counter: 'total', minimum: 3, answer: action === 'revive', progress: 1 }];
|
||||
game.revivalPromiseGuide = { action: action === 'revive'
|
||||
? { type: 'revive', amount: 2, elite: 0, freeElite: 0, revivalPromiseEvent: 'controlled-revival-event' }
|
||||
: { type: 'ready', revivalPromiseEvent: 'controlled-revival-event' } };
|
||||
return game;
|
||||
}
|
||||
|
||||
void test('Revival claim fields preserve the turn and author only parser-valid physical counter types and whole minima1 through20', () => {
|
||||
const value: RevivalClaim = { turn: 2, counter: 'total', minimum: 3 }, changes: RevivalClaim[] = [];
|
||||
const props = { value, onChange(next: RevivalClaim) { changes.push(next); } };
|
||||
const html = renderToStaticMarkup(createElement(RevivalClaimFields, props));
|
||||
assert.match(html, /Turn 2/); assert.match(html, /min="1"/); assert.match(html, /max="20"/);
|
||||
for (const type of ['total', 'normal', 'elite']) assert.match(html, new RegExp(`value="${type}"`));
|
||||
assert.match(html, /each physical counter counts once/);
|
||||
assert.match(html, /Earlier returns, card effects, Ghola and Emperor extra revivals do not count/);
|
||||
minimum(props, 1); minimum(props, 20); counter(props, 'normal'); counter(props, 'elite');
|
||||
assert.deepEqual(changes, [{ ...value, minimum: 1 }, { ...value, minimum: 20 },
|
||||
{ ...value, counter: 'normal' }, { ...value, counter: 'elite' }]);
|
||||
for (const next of [0, 21, -1, 1.5, NaN, Infinity]) {
|
||||
assert.ok(revivalClaimInputError({ ...value, minimum: next })); minimum(props, next);
|
||||
}
|
||||
counter(props, 'strength'); counter(props, ''); assert.equal(changes.length, 4);
|
||||
minimum({ ...props, disabled: true }, 2); counter({ ...props, disabled: true }, 'normal');
|
||||
minimum({ ...props, value: { ...value, turn: 0 } }, 2); assert.equal(changes.length, 4);
|
||||
assert.match(renderToStaticMarkup(createElement(RevivalClaimFields, { ...props, value: { ...value, minimum: 0 } })), /role="alert"/);
|
||||
});
|
||||
|
||||
void test('own Revival obligations persist with cumulative typed progress, exclusions and completion/release status', () => {
|
||||
const game = projected('revive', true);
|
||||
game.revivalPromises.push(
|
||||
{ event: 'no-event', player: 'p', asker: 'a', turn: 2, counter: 'elite', minimum: 2, answer: false, progress: 1 },
|
||||
{ event: 'fulfilled-event', player: 'p', asker: 'a', turn: 2, counter: 'normal', minimum: 1, answer: true, progress: 1, fulfilled: true },
|
||||
{ event: 'released-event', player: 'p', asker: 'a', turn: 2, counter: 'total', minimum: 9, answer: true, progress: 0, released: true },
|
||||
{ event: 'foreign-event', player: 'a', asker: 'p', turn: 2, counter: 'normal', minimum: 19, answer: true, progress: 0 },
|
||||
);
|
||||
game.revivalOrder!.remaining = ['a', 'p']; game.active = 'a'; game.revivalPromiseGuide = null;
|
||||
const before = structuredClone(game);
|
||||
for (let render = 0; render < 2; render++) {
|
||||
const html = markup(game);
|
||||
assert.match(html, /Your Revival promises/); assert.match(html, /Recorded future ordinary returns: 1 physical force/);
|
||||
assert.match(html, /Recorded future ordinary returns: 1 elite force/);
|
||||
assert.match(html, /No keeps your cumulative future returns below that minimum, including across smaller split requests/);
|
||||
assert.match(html, /Prior returns, card effects, Ghola and Emperor extra revivals do not count/);
|
||||
assert.match(html, /Original revival costs, limits and turn order still apply/);
|
||||
assert.match(html, /Fulfilled: this commitment is complete/); assert.match(html, /Released: this commitment is no longer possible/);
|
||||
assert.match(html, /Your Revival opportunity is still ahead/); assert.match(html, /href="\/rules#truthtrance-revival"/);
|
||||
assert.doesNotMatch(html, /at least 19|foreign-event|controlled-revival-event/);
|
||||
}
|
||||
assert.deepEqual(game, before);
|
||||
assert.equal(RevivalPromises({ game: { ...game, me: 'missing' }, busy: false, send() {} }), null);
|
||||
});
|
||||
|
||||
void test('actual Basic and ordered Advanced guide callbacks forward native revive or Ready packets unchanged', () => {
|
||||
for (const advanced of [false, true]) for (const action of ['revive', 'ready'] as const) {
|
||||
const game = projected(action, advanced), sent: Action[] = [];
|
||||
const props = { game, busy: false, send(next: Action) { sent.push(next); } };
|
||||
const button = guide(props); assert.equal(button.props.disabled, false); press(button);
|
||||
assert.deepEqual(sent, [game.revivalPromiseGuide!.action]); assert.equal(sent[0], game.revivalPromiseGuide!.action);
|
||||
press(guide({ ...props, busy: true })); assert.equal(sent.length, 1);
|
||||
}
|
||||
});
|
||||
|
||||
void test('stale Revival guides guard readiness, current ordered actor, event, quote and native action windows', () => {
|
||||
const changes: ((game: GameView) => void)[] = [
|
||||
game => { game.me = 'a'; }, game => { game.turn = 3; }, game => { game.phase = 5; },
|
||||
game => { game.status = 'setup'; }, game => { game.ready = ['p']; },
|
||||
game => { game.revivalOrder!.remaining = ['a', 'p']; game.active = 'a'; },
|
||||
game => { game.revivalOrder!.turn = 1; }, game => { game.active = null; },
|
||||
game => { game.revivalOrder = null; }, game => { game.revivalOrder = { ...game.revivalOrder!, remaining: ['p', 'a'] }; },
|
||||
game => { game.revivalPromiseGuide = { action: { ...game.revivalPromiseGuide!.action } }; },
|
||||
game => { game.revivalPromiseGuide = null; }, game => { game.revivalPromiseGuide!.action.amount = 3; },
|
||||
game => { game.revivalPromises[0].event = 'another-event'; }, game => { game.revivalPromises[0].player = 'a'; },
|
||||
game => { game.revivalPromises[0].fulfilled = true; }, game => { game.revivalPromises[0].released = true; },
|
||||
game => { game.revivalPromises = []; }, game => { game.phaseOpening = { passed: [] }; },
|
||||
game => { game.decision = { kind: 'revivalStop', player: 'a', recipient: 'p', revival: 'forces' }; },
|
||||
game => { game.response = { kind: 'revivalIncome', owner: 'a', amount: 1, passed: [] }; },
|
||||
game => { game.automaticContinuationPending = true; },
|
||||
game => { game.players.find(player => player.id === game.me)!.autopilot = 'Easy'; },
|
||||
game => { game.roomControl = { paused: true, joinLocked: false, revision: 1, updatedAt: null }; },
|
||||
game => { game.roomControl = { paused: false, closed: true, joinLocked: false, revision: 1, updatedAt: null }; },
|
||||
game => { game.techTokens = createTechTokens(); },
|
||||
game => { game.truthtrance = { stage: 'ask', queue: [{ player: 'a', card: 'controlled-card' }], passed: [], question: null }; },
|
||||
];
|
||||
for (const change of changes) {
|
||||
const game = projected('revive', true), sent: Action[] = [];
|
||||
const props = { game, busy: false, send(action: Action) { sent.push(action); } };
|
||||
const button = guide(props); change(game); press(button); assert.deepEqual(sent, []);
|
||||
}
|
||||
const props = { game: projected(), busy: false, send() { assert.fail('A now-busy guide cannot dispatch'); } };
|
||||
const button = guide(props); props.busy = true; press(button);
|
||||
});
|
||||
|
||||
void test('Revival guidance never reconstructs eligibility from private hands or rival physical pools', () => {
|
||||
const game = projected(), sent: Action[] = [];
|
||||
for (const seat of game.players) for (const key of ['hand', 'traitors', 'faceDancers', 'reserves', 'tanks', 'elites'])
|
||||
Object.defineProperty(seat, key, { get() { throw Error(`No private or unneeded ${key} inspection`); }, configurable: true });
|
||||
assert.match(markup(game), /Revive 2 forces while honoring your answers/);
|
||||
press(guide({ game, busy: false, send(action) { sent.push(action); } }));
|
||||
assert.deepEqual(sent, [game.revivalPromiseGuide!.action]);
|
||||
});
|
||||
|
||||
void test('source-earned Basic and Advanced Revival views connect the actual guide callback to completed ordinary progress', () => {
|
||||
for (const advanced of [false, true]) {
|
||||
const f = createTruthRevivalFixture({ advanced, window: 'active' });
|
||||
const bound = bindTruthRevival(f, 'yes', 3, 'total');
|
||||
const respondent = viewGame(bound.asked, f.target);
|
||||
assert.ok(respondent.truthRevivalAnswers?.includes('yes'));
|
||||
assert.equal(viewGame(bound.asked, f.asker).truthRevivalAnswers, null);
|
||||
const answers = renderToStaticMarkup(createElement(Truthtrance, { game: respondent, busy: false, act() {} }));
|
||||
assert.match(answers, /Answer Yes/); assert.match(answers, /Answer No/);
|
||||
const game = viewGame(bound.game, f.target), sent: Action[] = [];
|
||||
if (advanced) assert.equal(game.revivalOrder?.remaining[0], f.target);
|
||||
else assert.equal(game.revivalOrder, null);
|
||||
assert.match(markup(game), /Your Revival promises/); assert.match(markup(game), /Recorded future ordinary returns: 0 physical forces/);
|
||||
const next = game.revivalPromiseGuide; assert.ok(next);
|
||||
press(guide({ game, busy: false, send(action) { sent.push(action); } }));
|
||||
assert.deepEqual(sent, [next.action]);
|
||||
const after = settleTruthRevival(actTruthRevival(bound.game, f.target, sent[0]));
|
||||
const promise = after.revivalPromises?.find(promise => promise.event === next.action.revivalPromiseEvent);
|
||||
assert.ok(promise); assert.equal(promise.progress, 3); assert.equal(promise.fulfilled, true);
|
||||
assert.equal(after.players.find(player => player.id === f.target)!.tanks, 0);
|
||||
assert.match(markup(viewGame(after, f.target)), /Fulfilled: this commitment is complete/);
|
||||
assert.equal(RevivalPromiseGuideButton({ game: viewGame(after, f.target), busy: false, send() {} }), null);
|
||||
assert.equal(RevivalPromises({ game: viewGame(after, f.asker), busy: false, send() {} }), null);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import {
|
||||
createTruthRevivalFixture as fixture, declareTruthRevival as declare,
|
||||
actTruthRevival as act, askTruthRevival as ask, answerTruthRevival as answer,
|
||||
advanceTruthRevival as advance, finishTruthRevival as finish,
|
||||
truthRevivalClean as clean, truthRevivalPlayer as player, truthRevivalInventory as inventory,
|
||||
} from './fixture-truth-revival';
|
||||
|
||||
function reject(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => act(game, actor, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
const shipmentQuestion = (target: string) => ({ kind: 'shipment', target,
|
||||
claim: { territory: 'arrakeen', minimum: 3 } });
|
||||
|
||||
void test('earlier actual shipment Yes reserves its three spice when a later ordinary Revival question is answered', () => {
|
||||
const f = fixture();
|
||||
let game = declare(f, f.game, { copies: 2 }).game;
|
||||
game = act(game, f.asker, { type: 'truthAsk', question: shipmentQuestion(f.target) });
|
||||
assert.ok(viewGame(game, f.target).truthShipmentAnswers?.includes('yes'));
|
||||
game = answer(game, f.target, 'yes');
|
||||
game = ask(game, f.asker, f.target, 3);
|
||||
assert.deepEqual(viewGame(game, f.target).truthRevivalAnswers, ['no']);
|
||||
reject(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
game = answer(game, f.target, 'no');
|
||||
game = act(game, f.target, { type: 'revive', amount: 1, elite: 0 });
|
||||
assert.equal(player(game, f.target).spice, 4);
|
||||
assert.equal(game.revivalPromises![0].progress, 1);
|
||||
reject(game, f.target, { type: 'revive', amount: 2, elite: 0 });
|
||||
game = finish(game);
|
||||
game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state));
|
||||
game = act(game, f.target, { type: 'ship', territory: 'arrakeen', sector: 10, amount: 3, elite: 0, allyPayment: 0 });
|
||||
game = advance(game, clean);
|
||||
assert.equal(player(game, f.target).spice, 1);
|
||||
assert.equal(player(game, f.target).forces['arrakeen:10'], 3);
|
||||
assert.ok(game.shipmentPromises!.every(promise => promise.fulfilled));
|
||||
assert.ok(game.revivalPromises!.every(promise => promise.fulfilled));
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('earlier ordinary Revival Yes excludes an unaffordable later shipment Yes before the public answer', () => {
|
||||
const f = fixture();
|
||||
let game = declare(f, f.game, { copies: 2 }).game;
|
||||
game = ask(game, f.asker, f.target, 3);
|
||||
game = answer(game, f.target, 'yes');
|
||||
game = act(game, f.asker, { type: 'truthAsk', question: shipmentQuestion(f.target) });
|
||||
assert.deepEqual(viewGame(game, f.target).truthShipmentAnswers, ['no']);
|
||||
reject(game, f.target, { type: 'truthAnswer', answer: 'yes' });
|
||||
game = answer(game, f.target, 'no');
|
||||
const guide = viewGame(game, f.target).revivalPromiseGuide; assert.ok(guide);
|
||||
game = act(game, f.target, guide.action);
|
||||
assert.equal(player(game, f.target).spice, 0);
|
||||
assert.equal(player(game, f.target).tanks, 0);
|
||||
assert.ok(game.revivalPromises![0].fulfilled);
|
||||
game = finish(game);
|
||||
game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state));
|
||||
game = act(game, f.target, { type: 'endMovement' });
|
||||
assert.ok(game.shipmentPromises!.every(promise => promise.fulfilled));
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame, type Action, type Game } from '../game/engine';
|
||||
import { forceRevivalQuote } from '../game/revival';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import {
|
||||
actTruthRevival as act, advanceTruthRevival as advance, answerTruthRevival as answer,
|
||||
askTruthRevival as ask, bindTruthRevival as bind, createTruthRevivalFixture as fixture,
|
||||
declareTruthRevival as declare, finishTruthRevival as finish, settleTruthRevival as settle,
|
||||
truthRevivalClean as clean, truthRevivalInventory as inventory, truthRevivalPlayer as player,
|
||||
} from './fixture-truth-revival';
|
||||
|
||||
function rejected(game: Game, actor: string, action: Action) {
|
||||
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
|
||||
}
|
||||
const progress = (game: Game, target: string) => game.revivalPromises!.find(promise => promise.player === target)!;
|
||||
|
||||
for (const advanced of [false, true]) void test(`native ${advanced ? 'Advanced typed' : 'Basic'} ordinary Revival Yes earns original free and paid returns from real Storm casualties`, () => {
|
||||
const f = fixture({ advanced });
|
||||
assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup'); assert.deepEqual(f.staging, []);
|
||||
assert.deepEqual(f.game.expansions, []); assert.equal(f.originalCards.length, 33);
|
||||
assert.equal(f.sourceActionCount, f.sourceActions.length);
|
||||
assert.deepEqual(f.beforeStartingDeal.deck.slice(0, 4).map(card => card.id), f.deal);
|
||||
assert.equal(player(f.shipment.before, f.target).spice - player(f.shipment.after, f.target).spice, 6);
|
||||
assert.equal(player(f.shipment.after, f.target).forces[f.location], 3);
|
||||
assert.equal(player(f.storm.after, f.target).forces[f.location], undefined);
|
||||
assert.equal(player(f.storm.after, f.target).tanks, 3);
|
||||
assert.equal(player(f.storm.after, f.target).elites?.tanks ?? 0, advanced ? 1 : 0);
|
||||
const before = structuredClone(f.game), bound = bind(f);
|
||||
assert.deepEqual(f.game, before);
|
||||
assert.equal(bound.game.discard.filter(card => card.id === f.truth).length, 1);
|
||||
assert.equal(progress(bound.game, f.target).progress, 0);
|
||||
rejected(bound.game, f.target, { type: 'ready' });
|
||||
const guide = viewGame(bound.game, f.target).revivalPromiseGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type, 'revive'); assert.equal(guide.action.amount, 3); assert.equal(guide.action.elite, advanced ? 1 : 0);
|
||||
const quote = forceRevivalQuote(bound.game, player(bound.game, f.target), 3, advanced ? 1 : 0);
|
||||
assert.equal(quote.free, 1); assert.equal(quote.cost, 4);
|
||||
const returned = settle(act(bound.game, f.target, guide.action)), own = player(returned, f.target);
|
||||
assert.equal(own.spice, 0); assert.equal(own.reserves, 20); assert.equal(own.tanks, 0);
|
||||
assert.equal(own.revived, 3); assert.equal(own.freeForcesRevived, 1);
|
||||
if (advanced) { assert.equal(own.elites!.tanks, 0); assert.equal(own.elites!.reserves, 5); assert.equal(own.elites!.revived, 1); }
|
||||
assert.equal(progress(returned, f.target).progress, 3); assert.equal(progress(returned, f.target).fulfilled, true);
|
||||
assert.deepEqual(inventory(returned), f.originalCards);
|
||||
const done = finish(returned); assert.equal(done.phase, 5); assert.deepEqual(inventory(done), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native split ordinary returns fulfill cumulative Yes only after the second actual request', () => {
|
||||
const f = fixture(), bound = bind(f); let game = act(bound.game, f.target, { type: 'revive', amount: 1, elite: 0 });
|
||||
assert.equal(progress(game, f.target).progress, 1); assert.ok(!progress(game, f.target).fulfilled);
|
||||
assert.equal(player(game, f.target).spice, 4); rejected(game, f.target, { type: 'ready' });
|
||||
game = act(game, f.target, { type: 'revive', amount: 2, elite: 0 });
|
||||
assert.equal(progress(game, f.target).progress, 3); assert.equal(progress(game, f.target).fulfilled, true);
|
||||
assert.equal(player(game, f.target).spice, 0); assert.equal(player(game, f.target).freeForcesRevived, 1);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native No counts split ordinary requests together and permits an owned finish below its threshold', () => {
|
||||
const f = fixture(); let game = bind(f, 'no', 2).game;
|
||||
game = act(game, f.target, { type: 'revive', amount: 1, elite: 0 });
|
||||
assert.equal(progress(game, f.target).progress, 1);
|
||||
rejected(game, f.target, { type: 'revive', amount: 1, elite: 0 });
|
||||
const guide = viewGame(game, f.target).revivalPromiseGuide; assert.ok(guide); assert.equal(guide.action.type, 'ready');
|
||||
game = act(game, f.target, guide.action); game = finish(game);
|
||||
assert.equal(player(game, f.target).revived, 1); assert.equal(player(game, f.target).tanks, 2);
|
||||
assert.equal(progress(game, f.target).fulfilled, true); assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native Basic No survives an early Ready and permits only below-cap ordinary returns before phase close', () => {
|
||||
const f = fixture(); let game = bind(f, 'no', 3).game;
|
||||
game = act(game, f.target, { type: 'ready' });
|
||||
assert.equal(game.phase, 4); assert.ok(game.ready.includes(f.target));
|
||||
assert.ok(!progress(game, f.target).fulfilled);
|
||||
game = act(game, f.target, { type: 'revive', amount: 1, elite: 0 });
|
||||
assert.equal(progress(game, f.target).progress, 1); assert.equal(player(game, f.target).revived, 1);
|
||||
rejected(game, f.target, { type: 'revive', amount: 2, elite: 0 });
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native Advanced elite Yes counts one physical Sardaukar and ignores an earlier normal partial', () => {
|
||||
const f = fixture({ advanced: true }); let game = bind(f, 'yes', 1, 'elite').game;
|
||||
game = act(game, f.target, { type: 'revive', amount: 1, elite: 0 });
|
||||
assert.equal(progress(game, f.target).progress, 0); assert.equal(player(game, f.target).freeForcesRevived, 1);
|
||||
rejected(game, f.target, { type: 'ready' });
|
||||
const guide = viewGame(game, f.target).revivalPromiseGuide; assert.ok(guide); assert.equal(guide.action.elite, 1);
|
||||
game = act(game, f.target, guide.action);
|
||||
assert.equal(progress(game, f.target).progress, 1); assert.equal(progress(game, f.target).fulfilled, true);
|
||||
assert.equal(player(game, f.target).elites!.reserves, 5); assert.equal(player(game, f.target).elites!.tanks, 0);
|
||||
assert.equal(player(game, f.target).revived, 2); assert.equal(player(game, f.target).spice, 2);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native prior ordinary returns cannot fulfill a newly answered Revival Yes', () => {
|
||||
const f = fixture(); let game = act(f.game, f.target, { type: 'revive', amount: 1, elite: 0 });
|
||||
game = bind(f, 'yes', 2, 'total', game).game;
|
||||
assert.equal(player(game, f.target).revived, 1); assert.equal(progress(game, f.target).progress, 0);
|
||||
game = act(game, f.target, { type: 'revive', amount: 1, elite: 0 });
|
||||
assert.equal(progress(game, f.target).progress, 1); rejected(game, f.target, { type: 'ready' });
|
||||
game = act(game, f.target, { type: 'revive', amount: 1, elite: 0 });
|
||||
assert.equal(progress(game, f.target).progress, 2); assert.equal(player(game, f.target).spice, 0);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('two genuine Truth copies bind compatible Advanced normal and elite ordinary returns', () => {
|
||||
const f = fixture({ advanced: true }), declared = declare(f, f.game, { copies: 2 });
|
||||
let game = ask(declared.game, f.asker, f.target, 2, 'normal'); game = answer(game, f.target, 'yes');
|
||||
game = ask(game, f.asker, f.target, 1, 'elite'); game = answer(game, f.target, 'yes');
|
||||
for (let count = 0; game.revivalPromises!.some(promise => promise.answer && !promise.fulfilled && !promise.released) && count < 3; count++) {
|
||||
const guide = viewGame(game, f.target).revivalPromiseGuide; assert.ok(guide); game = act(game, f.target, guide.action);
|
||||
}
|
||||
assert.ok(game.revivalPromises!.every(promise => promise.fulfilled));
|
||||
assert.deepEqual(game.revivalPromises!.map(promise => promise.progress), [2, 1]);
|
||||
assert.equal(player(game, f.target).tanks, 0); assert.equal(player(game, f.target).spice, 0);
|
||||
for (const truth of f.truths) assert.equal(game.discard.filter(card => card.id === truth).length, 1);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('two genuine Truth copies reject an incompatible cumulative ordinary Revival answer', () => {
|
||||
const f = fixture(), declared = declare(f, f.game, { copies: 2 });
|
||||
let game = ask(declared.game, f.asker, f.target, 2); game = answer(game, f.target, 'no');
|
||||
game = ask(game, f.asker, f.target, 2);
|
||||
assert.deepEqual(viewGame(game, f.target).truthRevivalAnswers, ['no']);
|
||||
rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' }); game = answer(game, f.target, 'no');
|
||||
assert.equal(game.revivalPromises!.length, 2); assert.equal(game.discard.filter(card => f.truths.includes(card.id)).length, 2);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native promised payer cannot spend the four spice needed by its only ordinary Revival witness', () => {
|
||||
const f = fixture(), bound = bind(f);
|
||||
rejected(bound.game, f.target, { type: 'bribe', target: f.asker, amount: 1 });
|
||||
assert.equal(player(bound.game, f.target).spice, 4); assert.equal(progress(bound.game, f.target).progress, 0);
|
||||
});
|
||||
|
||||
void test('original Ghola return does not advance an ordinary Revival promise or consume its free quota', () => {
|
||||
const f = fixture({ targetGhola: true }); let game = bind(f, 'no', 2).game; assert.ok(f.ghola);
|
||||
game = settle(act(game, f.target, { type: 'card', card: f.ghola, amount: 1, elite: 0 }));
|
||||
assert.equal(progress(game, f.target).progress, 0); assert.equal(player(game, f.target).revived, 0);
|
||||
assert.equal(player(game, f.target).freeForcesRevived ?? 0, 0); assert.equal(player(game, f.target).tanks, 2);
|
||||
assert.equal(game.discard.filter(card => card.id === f.ghola).length, 1);
|
||||
game = act(game, f.target, { type: 'revive', amount: 1, elite: 0 });
|
||||
assert.equal(progress(game, f.target).progress, 1); assert.equal(player(game, f.target).freeForcesRevived, 1);
|
||||
rejected(game, f.target, { type: 'revive', amount: 1, elite: 0 }); assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('original Ghola cannot voluntarily withdraw the Tanks needed by a promised ordinary return', () => {
|
||||
const f = fixture({ targetGhola: true }), bound = bind(f); assert.ok(f.ghola);
|
||||
rejected(bound.game, f.target, { type: 'card', card: f.ghola, amount: 1, elite: 0 });
|
||||
assert.ok(player(bound.game, f.target).hand.some(card => card.id === f.ghola));
|
||||
assert.equal(player(bound.game, f.target).tanks, 3);
|
||||
});
|
||||
|
||||
void test('native waiting Advanced respondent keeps its future Revival witness until its original current opportunity', () => {
|
||||
const f = fixture({ advanced: true, window: 'waiting' }); assert.notEqual(f.game.revivalOrder!.remaining[0], f.target);
|
||||
let game = bind(f).game; assert.equal(viewGame(game, f.target).revivalPromiseGuide, null);
|
||||
game = advance(game, state => clean(state) && state.revivalOrder?.remaining[0] === f.target);
|
||||
const guide = viewGame(game, f.target).revivalPromiseGuide; assert.ok(guide);
|
||||
game = act(game, f.target, guide.action); assert.equal(progress(game, f.target).progress, 3);
|
||||
assert.equal(player(game, f.target).tanks, 0); assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native Tleilaxu response records no promised progress before the actual allowed typed return', () => {
|
||||
const f = fixture({ advanced: true, stopper: true }); let game = bind(f).game;
|
||||
const guide = viewGame(game, f.target).revivalPromiseGuide; assert.ok(guide); const before = structuredClone(player(game, f.target));
|
||||
game = act(game, f.target, guide.action);
|
||||
assert.equal(game.decision?.kind, 'revivalStop'); assert.equal(game.decision!.player, f.stopper);
|
||||
assert.equal(progress(game, f.target).progress, 0); assert.deepEqual(player(game, f.target), before);
|
||||
game = settle(act(game, f.stopper!, { type: 'decision', decline: true }));
|
||||
assert.equal(progress(game, f.target).progress, 3); assert.equal(progress(game, f.target).fulfilled, true);
|
||||
assert.equal(player(game, f.target).spice, 0); assert.equal(player(game, f.target).tanks, 0);
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native opposing Tleilaxu special Karama prevents the pending return and releases its impossible Yes', () => {
|
||||
const f = fixture({ advanced: true, stopper: true }); let game = bind(f).game; assert.ok(f.special && f.karama);
|
||||
const guide = viewGame(game, f.target).revivalPromiseGuide; assert.ok(guide);
|
||||
game = act(game, f.target, guide.action); assert.equal(progress(game, f.target).progress, 0);
|
||||
game = settle(act(game, f.stopper!, f.special));
|
||||
assert.equal(progress(game, f.target).progress, 0); assert.equal(progress(game, f.target).released, true);
|
||||
assert.equal(player(game, f.target).spice, 4); assert.equal(player(game, f.target).tanks, 3);
|
||||
assert.equal(player(game, f.target).revived, 0); assert.equal(player(game, f.target).freeForcesRevived ?? 0, 0);
|
||||
assert.equal(player(game, f.stopper!).specialKaramaUsed, true);
|
||||
assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
|
||||
game = act(game, f.target, { type: 'ready' }); assert.ok(game.revivalOrder!.completed.includes(f.target));
|
||||
assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('native Revival question ownership and stale guided actions preserve the actual current turn', () => {
|
||||
const f = fixture({ advanced: true }), declared = declare(f);
|
||||
rejected(declared.game, f.opponent, f.questionAction);
|
||||
rejected(declared.game, f.asker, { type: 'truthAsk', question: { ...f.question, turn: f.game.turn + 1 } });
|
||||
let game = ask(declared.game, f.asker, f.target);
|
||||
assert.deepEqual(viewGame(game, f.target).truthRevivalAnswers, ['yes', 'no']);
|
||||
assert.equal(viewGame(game, f.asker).truthRevivalAnswers, null);
|
||||
rejected(game, f.asker, { type: 'truthAnswer', answer: 'yes' }); game = answer(game, f.target, 'yes');
|
||||
const guide = viewGame(game, f.target).revivalPromiseGuide; assert.ok(guide); assert.ok(guide.action.revivalPromiseEvent);
|
||||
rejected(game, f.opponent, guide.action);
|
||||
game = act(game, f.target, guide.action); rejected(game, f.target, guide.action);
|
||||
game = act(game, f.target, { type: 'ready' }); assert.ok(game.revivalOrder!.completed.includes(f.target));
|
||||
rejected(game, f.target, guide.action); assert.deepEqual(inventory(game), f.originalCards);
|
||||
});
|
||||
|
||||
void test('all four minimal policies offer a legal ordinary return for the native Revival Yes', () => {
|
||||
const f = fixture(), game = bind(f).game;
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game, f.target); view.players.find(seat => seat.id === f.target)!.bot = difficulty;
|
||||
const action = botActions(view).find(action => action.type === 'revive' && action.amount === 3); assert.ok(action, difficulty);
|
||||
const accepted = act(structuredClone(game), f.target, action);
|
||||
assert.equal(progress(accepted, f.target).fulfilled, true); assert.equal(player(accepted, f.target).tanks, 0);
|
||||
assert.deepEqual(inventory(accepted), f.originalCards);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user