Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
6a810dc
docs: design full mdc combat exchange
Jul 21, 2026
301b006
docs: plan full mdc combat exchange
Jul 21, 2026
3e35537
feat(rules): stamp mega-damage exchange rules
Jul 21, 2026
fa4195a
feat(rules): derive terminal life state
Jul 21, 2026
d65299e
feat(rules): authorize mega-damage attacks
Jul 21, 2026
bad7e04
fix(rules): fingerprint mega-damage exchange rules
Jul 21, 2026
8237549
feat(rules): resolve tiered combat damage
Jul 21, 2026
4fcba42
feat(backend): validate tiered combat routes
Jul 21, 2026
13da576
fix(backend): persist mega-damage attacks
Jul 21, 2026
2b6cadb
fix(rules): store terminal state with current pools
Jul 21, 2026
6d7d3ac
feat(backend): enforce terminal life state
Jul 21, 2026
f066bcc
feat(backend): declare mega-damage attacks
Jul 21, 2026
d235426
fix(web): map combat target readiness
Jul 21, 2026
af3f45e
feat(backend): persist tiered combat outcomes
Jul 21, 2026
ff4d35b
fix(combat): stale newly dead attackers
Jul 21, 2026
426b24a
feat(web): present tiered combat outcomes
Jul 21, 2026
c2a6a08
fix(web): inherit combat result tones
Jul 21, 2026
daa03f0
feat(web): render terminal character dossiers
Jul 21, 2026
780dd91
fix(combat): harden mega-damage invariants
Jul 21, 2026
f67236e
fix(web): harden dossier shell acceptance
Jul 21, 2026
1ce2130
docs: record mega-damage combat delivery
Jul 21, 2026
16cb799
docs: link mega-damage combat review
Jul 21, 2026
4e0afc5
docs: format combat delivery evidence
Jul 21, 2026
4f5f0bb
docs: record CodeRabbit approval
Jul 21, 2026
1d23ada
test(web): fail missing source contracts
Jul 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
482 changes: 482 additions & 0 deletions .codex/superpowers/plans/2026-07-21-mdc-combat-exchange.md

Large diffs are not rendered by default.

691 changes: 691 additions & 0 deletions .codex/superpowers/specs/2026-07-21-mdc-combat-exchange-design.md

Large diffs are not rendered by default.

17 changes: 10 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,16 @@ apps/web/

The engine is isomorphic: the Convex backend derives sheets at query time, and the
client imports the same package for ephemeral rolls. The runtime never parses the
source book. Combat APIs now support persisted, server-rolled S.D.C. weapon
exchanges with engine-authorized defense choices, immutable history, and atomic
body S.D.C.-then-Hit-Point routing. Artificial S.D.C. armor A.R. and ablation
routing are pure-tested and ready for page-stamped production content. Natural
A.R. is intentionally absent per RUE p.339; full M.D.C. interaction remains
follow-up work. Spell APIs expose load-validated structured damage, derivation,
and injected-RNG rolling.
source book. Combat APIs support persisted, server-rolled S.D.C. and M.D. weapon
exchanges through one engine-authorized protocol. The delivered core covers
critical-before-conversion damage, S.D.C./M.D.C. cross-tier routing, S.D.C. armor
A.R. and ablation, intact and depleted M.D.C. armor, final-blast absorption with
no spill, immutable route evidence, atomic body damage, and explicit terminal
death. Legacy S.D.C. exchange history remains readable without migration.
Natural A.R. is intentionally absent per RUE p.339. The optional near-fatal M.D.
injury and survival procedure remains deferred to issue #54, and a generic
page-stamped effects pipeline remains roadmap work in issue #53. Spell APIs expose
load-validated structured damage, derivation, and injected-RNG rolling.

### Current status

Expand Down
1 change: 1 addition & 0 deletions apps/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>RiftForge</title>
<link rel="icon" href="data:," />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
Expand Down
288 changes: 154 additions & 134 deletions apps/web/src/components/combat-exchange-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
combatErrorMessage,
combatTargetDisabledReason,
combatWeaponChoices,
exchangeResultLabel,
exchangeTone,
formatExchangeSummary,
ownsAsyncResult,
Expand Down Expand Up @@ -302,6 +303,7 @@ function OutgoingExchangeRow(props: {
export interface CombatExchangePanelProps {
characterId: Id<"characters">;
sheet: CharacterSheet;
gameplayDisabledReason?: string;
onTelemetry: (text: string, tone?: TelemetryTone) => void;
}

Expand Down Expand Up @@ -331,6 +333,7 @@ export function CombatExchangePanel(props: CombatExchangePanelProps): JSX.Elemen
const [notice, setNotice] = createSignal<DeclarationNotice>();
const [historyExpanded, setHistoryExpanded] = createSignal(false);
const [flashingIds, setFlashingIds] = createSignal<ReadonlySet<string>>(new Set());
const gameplayEnabled = () => props.gameplayDisabledReason === undefined;
const choices = createMemo(() => combatWeaponChoices(props.sheet));
const selectedIndex = createMemo(() => {
const raw = weaponIndex();
Expand All @@ -352,6 +355,7 @@ export function CombatExchangePanel(props: CombatExchangePanelProps): JSX.Elemen
const attack = selectedSupportedAttack();
const modifier = modifierValue();
return (
gameplayEnabled() &&
!busy() &&
target !== undefined &&
combatTargetDisabledReason(target) === undefined &&
Expand Down Expand Up @@ -521,147 +525,162 @@ export function CombatExchangePanel(props: CombatExchangePanelProps): JSX.Elemen
return (
<Panel class="space-y-3 p-3">
<SectionTitle>COMBAT EXCHANGE</SectionTitle>
<div class="space-y-2 border-t border-line pt-2">
<label class="block space-y-1">
<MonoLabel class="block">TARGET</MonoLabel>
<SelectInput
class="w-full"
value={targetId()}
onChange={(event) => setTargetId(event.currentTarget.value)}
>
<option value="">SELECT DOSSIER</option>
<For each={targets.data()}>
{(target) => (
<option
value={target.id}
disabled={combatTargetDisabledReason(target) !== undefined}
<Show when={props.gameplayDisabledReason}>
{(reason) => (
<Alert tone="danger">
<MonoLabel class="mr-2 !text-inherit">LIFE SIGNS TERMINATED</MonoLabel>
{reason()}
</Alert>
)}
</Show>

<Show when={gameplayEnabled()}>
<div class="space-y-2 border-t border-line pt-2">
<label class="block space-y-1">
<MonoLabel class="block">TARGET</MonoLabel>
<SelectInput
class="w-full"
value={targetId()}
onChange={(event) => setTargetId(event.currentTarget.value)}
>
<option value="">SELECT DOSSIER</option>
<For each={targets.data()}>
{(target) => (
<option
value={target.id}
disabled={combatTargetDisabledReason(target) !== undefined}
>
{target.name}
{combatTargetDisabledReason(target)
? ` — ${combatTargetDisabledReason(target)}`
: ""}
</option>
)}
</For>
</SelectInput>
</label>
<label class="block space-y-1">
<MonoLabel class="block">WEAPON</MonoLabel>
<SelectInput
class="w-full"
value={weaponIndex()}
onChange={(event) => setWeaponIndex(event.currentTarget.value)}
>
<option value="">SELECT WEAPON</option>
<For each={choices()}>
{(choice) => (
<option value={choice.index} disabled={!choice.supported}>
{choice.label}
{choice.disabledReason ? ` — ${choice.disabledReason}` : ""}
</option>
)}
</For>
</SelectInput>
</label>
<div class="flex items-end gap-2">
<ToggleChip pressed={aware()} onToggle={() => setAware((value) => !value)}>
AWARE
</ToggleChip>
<Show when={selectedSupportedAttack()?.kind === "melee"}>
<label class="min-w-0 flex-1 space-y-1">
<MonoLabel class="block">PARRY MODE</MonoLabel>
<SelectInput
class="w-full"
value={parryMode()}
onChange={(event) => setParryMode(event.currentTarget.value as ParryMode)}
>
{target.name}
{combatTargetDisabledReason(target)
? ` — ${combatTargetDisabledReason(target)}`
: ""}
</option>
)}
</For>
</SelectInput>
</label>
<label class="block space-y-1">
<MonoLabel class="block">WEAPON</MonoLabel>
<SelectInput
class="w-full"
value={weaponIndex()}
onChange={(event) => setWeaponIndex(event.currentTarget.value)}
>
<option value="">SELECT WEAPON</option>
<For each={choices()}>
{(choice) => (
<option value={choice.index} disabled={!choice.supported}>
{choice.label}
{choice.disabledReason ? ` — ${choice.disabledReason}` : ""}
</option>
)}
</For>
</SelectInput>
</label>
<div class="flex items-end gap-2">
<ToggleChip pressed={aware()} onToggle={() => setAware((value) => !value)}>
AWARE
</ToggleChip>
<Show when={selectedSupportedAttack()?.kind === "melee"}>
<label class="min-w-0 flex-1 space-y-1">
<MonoLabel class="block">PARRY MODE</MonoLabel>
<SelectInput
<option value="unavailable">UNAVAILABLE</option>
<option value="standard">STANDARD</option>
<option value="bareHanded">BARE-HANDED</option>
</SelectInput>
</label>
</Show>
<Show when={selectedSupportedAttack()?.kind === "ranged"}>
<label class="min-w-0 flex-1 space-y-1">
<MonoLabel class="block">RANGE BAND</MonoLabel>
<SelectInput
class="w-full"
value={rangeBand()}
onChange={(event) => setRangeBand(event.currentTarget.value as RangeBand)}
>
<option value="pointBlank">POINT-BLANK</option>
<option value="close">CLOSE</option>
<option value="normal">NORMAL</option>
</SelectInput>
</label>
</Show>
</div>
<div class="grid grid-cols-[72px_minmax(0,1fr)] gap-2">
<label class="space-y-1">
<MonoLabel class="block">MOD</MonoLabel>
<TextInput
aria-label="Strike modifier"
inputmode="numeric"
class="w-full"
value={parryMode()}
onChange={(event) => setParryMode(event.currentTarget.value as ParryMode)}
>
<option value="unavailable">UNAVAILABLE</option>
<option value="standard">STANDARD</option>
<option value="bareHanded">BARE-HANDED</option>
</SelectInput>
value={strikeModifier()}
onInput={(event) => setStrikeModifier(event.currentTarget.value)}
/>
</label>
</Show>
<Show when={selectedSupportedAttack()?.kind === "ranged"}>
<label class="min-w-0 flex-1 space-y-1">
<MonoLabel class="block">RANGE BAND</MonoLabel>
<SelectInput
<label class="space-y-1">
<MonoLabel class="block">REASON</MonoLabel>
<TextInput
aria-label="Strike modifier reason"
class="w-full"
value={rangeBand()}
onChange={(event) => setRangeBand(event.currentTarget.value as RangeBand)}
>
<option value="pointBlank">POINT-BLANK</option>
<option value="close">CLOSE</option>
<option value="normal">NORMAL</option>
</SelectInput>
value={strikeReason()}
onInput={(event) => setStrikeReason(event.currentTarget.value)}
/>
</label>
</div>
<Button
variant="primary"
class="w-full text-left"
disabled={!canDeclare()}
onClick={() => void submitDeclaration()}
>
{busy() ? "> TRANSMITTING…" : "> DECLARE ATTACK"}
</Button>
<Show when={notice()}>
{(message) => <Alert tone={message().tone}>{message().text}</Alert>}
</Show>
<Show when={error()}>{(message) => <Alert tone="danger">{message()}</Alert>}</Show>
<Show when={targets.error()}>
{(queryError) => (
<Alert tone="danger">TARGET LINK FAILED — {queryError().message}</Alert>
)}
</Show>
</div>
<div class="grid grid-cols-[72px_minmax(0,1fr)] gap-2">
<label class="space-y-1">
<MonoLabel class="block">MOD</MonoLabel>
<TextInput
aria-label="Strike modifier"
inputmode="numeric"
class="w-full"
value={strikeModifier()}
onInput={(event) => setStrikeModifier(event.currentTarget.value)}
/>
</label>
<label class="space-y-1">
<MonoLabel class="block">REASON</MonoLabel>
<TextInput
aria-label="Strike modifier reason"
class="w-full"
value={strikeReason()}
onInput={(event) => setStrikeReason(event.currentTarget.value)}
/>
</label>
</div>
<Button
variant="primary"
class="w-full text-left"
disabled={!canDeclare()}
onClick={() => void submitDeclaration()}
>
{busy() ? "> TRANSMITTING…" : "> DECLARE ATTACK"}
</Button>
<Show when={notice()}>
{(message) => <Alert tone={message().tone}>{message().text}</Alert>}
</Show>
<Show when={error()}>{(message) => <Alert tone="danger">{message()}</Alert>}</Show>
<Show when={targets.error()}>
{(queryError) => <Alert tone="danger">TARGET LINK FAILED — {queryError().message}</Alert>}
</Show>
</div>
</Show>

<section class="border-t border-line pt-2" aria-labelledby="combat-incoming-title">
<MonoLabel class="block text-dead" id="combat-incoming-title">
INCOMING
</MonoLabel>
<Show
when={incomingIds().length > 0}
fallback={<p class="mt-1 font-mono text-[11.5px] text-dead">// NO PENDING STRIKES</p>}
>
<ol class="mt-2 space-y-2">
<For each={incomingIds()}>
{(exchangeId) => (
<IncomingExchangeRow
exchangeId={exchangeId}
exchange={() => incomingById(exchangeId)}
characterId={props.characterId}
routeEpoch={() => routeEpoch}
onTelemetry={props.onTelemetry}
/>
)}
</For>
</ol>
</Show>
<Show when={incoming.error()}>
{(queryError) => (
<Alert tone="danger">INCOMING LINK FAILED — {queryError().message}</Alert>
)}
</Show>
</section>
<Show when={gameplayEnabled()}>
<section class="border-t border-line pt-2" aria-labelledby="combat-incoming-title">
<MonoLabel class="block text-dead" id="combat-incoming-title">
INCOMING
</MonoLabel>
<Show
when={incomingIds().length > 0}
fallback={<p class="mt-1 font-mono text-[11.5px] text-dead">// NO PENDING STRIKES</p>}
>
<ol class="mt-2 space-y-2">
<For each={incomingIds()}>
{(exchangeId) => (
<IncomingExchangeRow
exchangeId={exchangeId}
exchange={() => incomingById(exchangeId)}
characterId={props.characterId}
routeEpoch={() => routeEpoch}
onTelemetry={props.onTelemetry}
/>
)}
</For>
</ol>
</Show>
<Show when={incoming.error()}>
{(queryError) => (
<Alert tone="danger">INCOMING LINK FAILED — {queryError().message}</Alert>
)}
</Show>
</section>
</Show>

<section class="border-t border-line pt-2" aria-labelledby="combat-outgoing-title">
<MonoLabel class="block text-dead" id="combat-outgoing-title">
Expand Down Expand Up @@ -723,7 +742,8 @@ export function CombatExchangePanel(props: CombatExchangePanelProps): JSX.Elemen
exchange.status === "resolved" && flashingIds().has(String(exchange._id)),
}}
>
{formatExchangeSummary(exchange)}
<MonoLabel class="block !text-inherit">{exchangeResultLabel(exchange)}</MonoLabel>
<span class="block">{formatExchangeSummary(exchange)}</span>
</li>
)}
</For>
Expand Down
Loading
Loading