Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
72 commits
Select commit Hold shift + click to select a range
e924a9b
ship/fix desktop show native engine provisioning status (#6546)
matthewevans Jul 23, 2026
95ecfb2
fix(menu): avoid WebKit button paint layer (#6547)
matthewevans Jul 23, 2026
6ae8737
release: v0.35.1
matthewevans Jul 23, 2026
1ddb30c
fix(ui): surface roll outcomes and turn order (#6549)
matthewevans Jul 23, 2026
e731b73
fix(client): restore split opponent hand visibility (#6555)
matthewevans Jul 23, 2026
0b38b1a
fix(client): respect legal Adventure cast faces (#6552)
matthewevans Jul 23, 2026
73edc91
cr733(p2): journal the zone-change transition core (#6551)
matthewevans Jul 23, 2026
8985e8c
fix(desktop): replay native engine progress (#6550)
matthewevans Jul 23, 2026
1dd966a
cr733(p2): journal cost-settlement consume-before-priority occurrence…
matthewevans Jul 23, 2026
9613bcc
cr733(p2): journal draw bookkeeping as a ledger edit (composes zone-c…
matthewevans Jul 23, 2026
21a53d5
release: v0.35.2
matthewevans Jul 23, 2026
cb7d5a2
fix(ci): sync client/src-tauri/Cargo.lock with the v0.35.2 version bu…
matthewevans Jul 23, 2026
6a3d080
fix(release): keep client/src-tauri/Cargo.lock in sync on version bum…
matthewevans Jul 23, 2026
d896881
docs(pr-review-loop): encode review-sweep defects found in the 2026-0…
matthewevans Jul 24, 2026
869dafe
chore(deps): bump react-router (#6575)
dependabot[bot] Jul 24, 2026
92e1269
ci: give the "Latest" release badge to the desktop installer (#6578)
matthewevans Jul 24, 2026
0bd5c01
Fix Lictor opponent-scoped "entered this turn" intervening-if (#6554)
keloide Jul 23, 2026
c46f3f1
fix(engine): unearth's leave-battlefield exile replacement must survi…
michiot05 Jul 24, 2026
59d8250
fix(engine): keep Portent exile picks out of the graveyard (#6498) (#…
andriypolanski Jul 24, 2026
e3d6b71
fix(engine): honor dynamically granted Sunburst at battlefield entry …
shin-core Jul 24, 2026
ba0a5cf
Fix Fevered Visions (#6563)
invalidCards Jul 24, 2026
101a2f4
feat(phase-ai): add poison-clock deck-feature axis + PoisonClockPolic…
minion1227 Jul 24, 2026
bb58f44
docs(contributor): require Frontier-tier models; encode review-loop d…
matthewevans Jul 24, 2026
7a95408
fix(native): dial phase-server's /ws route, unpin the provisioning ov…
matthewevans Jul 24, 2026
7de942f
fix(engine): keep a "return to hand" card as the referent for "that c…
philluiz2323 Jul 24, 2026
ad0ded6
fix: correct Wheel and Deal targets (#6571)
invalidCards Jul 24, 2026
8c52465
policy(contributor): grandfather pre-cutoff tier declarations; reap d…
matthewevans Jul 24, 2026
0a73eef
fix(engine): Metamorphic Alteration as-enters choose + host copy (#60…
claytonlin1110 Jul 24, 2026
ea60d3e
policy(pr-review): revert skip-to-close-on-sight; gate closures on di…
matthewevans Jul 24, 2026
b262864
policy(contributor): per-vendor Frontier floor; state model gate by e…
matthewevans Jul 24, 2026
22e7716
feat(phase-ai): add graveyard type-diversity axis + GraveyardTypesPol…
minion1227 Jul 24, 2026
44cebb0
fix(engine): honor CR 702.16n protection Aura exemption (Flickering W…
andriypolanski Jul 24, 2026
2975e41
fix(engine): granted "leave the battlefield → exile instead" riders m…
michiot05 Jul 24, 2026
4006c02
fix(ui): reset Alt-pinned card preview when switching to another card…
matthewevans Jul 24, 2026
312251d
feat(native-engine): resumable solo games + fix Windows draft-pools b…
matthewevans Jul 24, 2026
7ed59c0
chore: refresh metagame feeds (#6589)
matthewevans Jul 24, 2026
6895ca3
release: v0.36.0
Jul 24, 2026
7d009a0
Fix Circle of Power: preserve typed-group continuous sibling after a …
claude Jul 24, 2026
0f08497
fix(parser): scope "that player" to triggering player for Aura/Equipm…
hitomi1 Jul 24, 2026
2c58e27
fix(parser): bind "that player controls" to the iterated opponent for…
jsdevninja Jul 24, 2026
f59b33c
feat(engine): implement CR 710 Kamigawa flip cards (#6569)
real-venus Jul 24, 2026
ad26712
fix(parser): "play a land or cast a spell" is a play-card trigger (Sh…
bitfathers94 Jul 24, 2026
8eda19c
fix(parser): lower Doppelgang's inline for-each-of-target copy (#6509…
bitfathers94 Jul 24, 2026
2cdb2b8
fix(native-engine): suppress Windows console window when spawning pha…
matthewevans Jul 24, 2026
192897c
chore(changelog): publish entry #180 — resume solo games on desktop (…
matthewevans Jul 24, 2026
7ac9d51
fix(client): use browser :hover for card-preview dismiss checks (#6600)
matthewevans Jul 24, 2026
4dd28b1
docs: require complete AI contributor PR template (#6601)
matthewevans Jul 24, 2026
438cad0
ship/ci cache banking and janitor (#6603)
matthewevans Jul 24, 2026
731cffc
fix(engine): activate a zero-card "discard your hand" cost with an em…
michiot05 Jul 24, 2026
f60f590
fix(engine): Gift recipient selection for Peerless Recycling (#5981) …
claytonlin1110 Jul 24, 2026
3dbe341
fix(parser): distribute Mutable Pupa's perpetual keyword-mirror trigg…
jsdevninja Jul 24, 2026
ebe1294
fix(native-engine): reuse cached server and restore saved sessions (#…
matthewevans Jul 24, 2026
2c6596d
fix(client): size mana cost pips to the printed cost, not a fixed px …
matthewevans Jul 24, 2026
b056ad6
test(engine): runtime regression for Riptide Gearhulk's optional per-…
galuis116 Jul 24, 2026
ee189aa
fix(client): distinguish a permanent's mana abilities and render the …
lgray Jul 24, 2026
40a36f6
feat: show temporary unblockable effects (#6611)
matthewevans Jul 25, 2026
4d1cb73
perf(test): cap the 3p loop-shortcut grind drives at the measured pri…
lgray Jul 25, 2026
8b39ff1
Fix native P2P startup adapter race (#6614)
matthewevans Jul 25, 2026
3423ebc
Fix native multi-AI setup frame limit (#6615)
matthewevans Jul 25, 2026
be45b8c
fix(engine): repeat Grindstone mill while milled cards share a color …
andriypolanski Jul 25, 2026
6ae9c62
fix: clarify replacement modal choices (#6612)
matthewevans Jul 25, 2026
f027bd9
perf(deploy): overlap preview server with data publication (#6616)
matthewevans Jul 25, 2026
616d5fd
fix(engine): enforce event-bound forced blocks (#6613)
matthewevans Jul 25, 2026
dddd313
perf(ci): cache Rust compilation with sccache (#6617)
matthewevans Jul 25, 2026
4782c0c
fix(p2p): deduplicate host initialization (#6618)
matthewevans Jul 25, 2026
da4879f
fix(ci): disable incremental builds for sccache (#6619)
matthewevans Jul 25, 2026
83c309b
fix(deploy): include localized card data in preview inputs
matthewevans Jul 25, 2026
b3bd4f9
fix(engine): a player-directed "exile a creature they control" is a c…
michiot05 Jul 25, 2026
799b342
fix(engine): carry the sacrificed-permanent quality on Devour (Famish…
michiot05 Jul 25, 2026
6346c26
fix(deploy): transfer all generated preview data
matthewevans Jul 25, 2026
be6883b
fix(parser): bind "that token" inside a reflexive "when you do" body …
lgray Jul 25, 2026
6c58d87
Merge remote-tracking branch 'origin/main' into pr/6622-handler-20260725
matthewevans Jul 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
87 changes: 86 additions & 1 deletion .claude/skills/pr-review-loop/SKILL.md

Large diffs are not rendered by default.

36 changes: 34 additions & 2 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,23 @@

<!-- What this PR changes and why. One or two sentences. -->

## Files changed

<!-- Every changed path, as a short bullet list. -->

## Track

<Developer | Non-developer>

## LLM

Model: <actual Frontier model identifier | not-applicable (no LLM)>
Tier: <Frontier | not-applicable (no LLM)>
Thinking: <high | max | not-applicable (no LLM)>

<!-- Keep Model and Tier as exact, standalone canonical lines. AI-assisted PRs
must report their actual Frontier model, Tier: Frontier, and thinking level. -->

## Implementation method (required)

Method: /engine-implementer
Expand Down Expand Up @@ -42,5 +59,20 @@ Final review-impl PASS head=<40-hex-sha>

## Claimed parse impact

- <Exact Card Name>
<!-- Optional manual quality evidence; not an admission artifact. -->
None.
<!-- List exact card names only when the parse-diff changes cards. -->

## Scope Expansion

None.
<!-- Describe any change that crosses the issue/card's stated scope. -->

## Validation Failures

None.
<!-- Replace with the unresolved validation failure and its evidence. -->

## CI Failures

None.
<!-- Replace with the unresolved CI failure and its evidence. -->
63 changes: 62 additions & 1 deletion .github/actions/mtgjson-cache-key/action.yml
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
name: MTGJSON cache key
description: >
Derive the mtgjson-full cache-key suffix from MTGJSON's published release
version, so the cache rolls exactly when new data is published.
version, so the cache rolls exactly when new data is published, plus the
paired draft-pool suffix for the artifact generated from that same data.

outputs:
suffix:
description: Published MTGJSON version (sanitized), or a week-based fallback.
value: ${{ steps.probe.outputs.suffix }}
draft_pools_suffix:
description: >
Cache-key suffix for the generated draft-pools.json — the MTGJSON data
version plus a hash of the sources and gating config that define the
output.
value: ${{ steps.pools.outputs.suffix }}

runs:
using: composite
Expand Down Expand Up @@ -44,3 +51,57 @@ runs:
suffix="week-$(date -u +%Y-W%V)"
fi
echo "suffix=$suffix" >> "$GITHUB_OUTPUT"

- name: Derive draft-pool cache suffix
id: pools
shell: bash
env:
MTGJSON_SUFFIX: ${{ steps.probe.outputs.suffix }}
# draft-pools.json is a pure function of four inputs, and all four are in
# this suffix so a restored pool is current by construction:
#
# 1. the MTGJSON per-set files → MTGJSON_SUFFIX (above)
# 2. the draft-core crate → src_hash
# 3. engine's set catalog + gating → src_hash
# 4. GATED_SETS (+ its as-of date) → gate
#
# Deliberately narrow on the engine side: `extraction.rs` deserializes
# MTGJSON directly and never calls into engine, so the only engine code
# that can move the output is the set catalog / release gate the bin
# reads. Routine parser churn under crates/engine therefore does NOT
# roll this key. Widen it here if draft-pool-gen ever reads more.
run: |
set -euo pipefail
paths=(
crates/draft-core
crates/engine/src/database/set_catalog.rs
crates/engine/src/database/set_gating.rs
)
# Validate each path INDEPENDENTLY. A combined emptiness check only
# catches total loss: `crates/draft-core` alone matches 13 tracked
# files, so renaming either single-file engine path would leave the
# combined output non-empty, pass the guard, and silently drop that
# file from the hash — the key would then stop rolling on its content
# for good, which is the exact staleness this guard exists to prevent.
for p in "${paths[@]}"; do
if [ -z "$(git ls-files -s "$p")" ]; then
echo "::error::draft-pool source path '$p' matched no tracked files — update .github/actions/mtgjson-cache-key"
exit 1
fi
done
# `git ls-files -s` yields the tracked blob SHAs of the checked-out
# tree — a content hash with no file reads.
src_hash="$(git ls-files -s "${paths[@]}" | sha256sum | cut -c1-16)"
# Release gating is generation-time input, and gated sets auto-unlock
# once their release date passes as of GATED_SETS_AS_OF (default UTC
# today — see engine::database::set_gating). So while gating is active
# the key must roll daily, or a cached pool would keep hiding a set
# that has since released. Unset (the CI default) → no date in the
# key, and the entry survives across days. Commas are not cache-key
# material, so they become underscores.
if [ -n "${GATED_SETS:-}" ]; then
gate="$(printf '%s' "$GATED_SETS" | tr -d '[:space:]' | tr ',' '_')-${GATED_SETS_AS_OF:-$(date -u +%Y-%m-%d)}"
else
gate="ungated"
fi
echo "suffix=${MTGJSON_SUFFIX}-${src_hash}-${gate}" >> "$GITHUB_OUTPUT"
206 changes: 206 additions & 0 deletions .github/workflows/cache-janitor.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
name: Cache janitor

# Deletes Actions caches that nothing can ever restore again.
#
# The repo runs against a hard 10 GB cache quota; once over it, GitHub evicts by
# least-recently-used. On 2026-07-24 the repo sat at 9.93 GB across 207 entries,
# and the eviction pressure was almost entirely dead weight: 141 of those 207
# entries (3.4 GB) had been written and never restored even once.
#
# The waste is not "old" or "big" entries — it is entries whose SCOPE is dead.
# A cache is restorable only from its own ref or from the default branch, so:
#
# * gh-readonly-queue/** The merge queue re-synthesizes these branches on
# every entry change and deletes the old ones. 58 of
# 58 such entries (1.00 GB) had never been read, and
# 27 distinct queue refs held caches while only 3 were
# still live. Queue runs restore from main (their base)
# anyway, so nothing is lost.
# * refs/pull/N/merge Useful across pushes WITHIN a live PR (23 entries had
# genuinely been re-read), and dead the moment the PR
# closes. 17 of the 25 PRs holding caches were closed,
# holding 1.84 GB.
# * refs/tags/** Release runs happen once per tag, and a later tag is
# a different ref that cannot read them. 8 of 9 (1.33
# GB) had never been read.
#
# Deliberately NOT implemented: generational pruning ("keep the newest N per key
# family"). It was measured first and does not pay — every rust family on main
# already carries exactly 2 generations, so a keep-newest-2 sweep would reclaim
# 0.13 GB, and dropping to keep-1 would delete the older generation that
# Swatinem's rust-cache uses as its restore-keys warm-start fallback. Main's
# caches are healthy (42 of 46 re-read); this janitor never touches them.
#
# Mirrors merge-queue-janitor.yml's test: ref-absence is a scope test, not a
# liveness test. Nothing here inspects age or size on the queue path — if the
# ref is gone, the entry is unreachable by definition.

on:
schedule:
# Daily at 06:00 UTC, before the working day builds up new entries.
- cron: "0 6 * * *"
pull_request_target:
# Reclaim a PR's caches as soon as it closes, rather than waiting for the
# next nightly sweep or GitHub's 7-day idle eviction.
#
# `pull_request_target`, not `pull_request`: a fork-originated
# `pull_request` event gets a read-only GITHUB_TOKEN no matter what the
# `permissions:` block asks for, so every closed fork PR would 403 on the
# delete and leave a failed run behind. This repo takes fork
# contributions, so that is the common case, not the edge case.
# `pull_request_target` runs this workflow definition from the base branch
# with a writable token, and it is safe here because nothing in this job
# checks out or executes PR code — it only calls the caches API.
types: [closed]
workflow_dispatch:
inputs:
dry_run:
description: "List what would be deleted without deleting it"
type: boolean
default: false

permissions:
actions: write
contents: read

concurrency:
group: cache-janitor
cancel-in-progress: false

jobs:
sweep:
name: Delete unreachable caches
runs-on: ubuntu-latest
timeout-minutes: 10
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
DRY_RUN: ${{ inputs.dry_run || 'false' }}
# PR-close runs sweep only the PR that closed; scheduled and manual runs
# sweep everything.
CLOSED_PR: ${{ github.event_name == 'pull_request_target' && github.event.number || '' }}
# Tag caches are kept briefly so a re-run of the same release can still
# use them, then reclaimed.
TAG_CACHE_MAX_AGE_DAYS: "3"
steps:
- name: Delete caches whose scope can never restore them again
run: |
set -euo pipefail

# Full inventory. The REST route is paginated at 100; --paginate
# walks it. `.actions_caches[]` is the per-entry array. created_at is
# pulled here so the tag-age check needs no per-entry API call.
gh api --paginate "/repos/$REPO/actions/caches?per_page=100" \
--jq '.actions_caches[] | [.id, .ref, .size_in_bytes, .created_at, .key] | @tsv' > caches.tsv
echo "inventory: $(wc -l < caches.tsv) entries, $(awk -F'\t' '{s+=$3} END {printf "%.2f", s/1073741824}' caches.tsv) GB"

# Live merge-queue refs, so an in-flight queue entry is never swept.
# A FAILED lookup is missing evidence, not an empty live set: an API
# blip would otherwise leave this file empty, mark every queue ref
# orphaned, and delete the caches of entries currently in flight.
# matching-refs returns 200 with `[]` when nothing matches, so a
# genuinely empty live set still succeeds here and sweeps correctly.
# Only needed when the queue scope is in play — a PR-close run must
# not fail on an API blip for a scope it isn't going to touch.
: > live_queue.txt
if [ -z "$CLOSED_PR" ]; then
if ! gh api "/repos/$REPO/git/matching-refs/heads/gh-readonly-queue" \
--jq '.[].ref | sub("^refs/heads/"; "")' > live_queue.raw; then
echo "::error::could not enumerate live merge-queue refs — refusing to sweep on missing evidence"
exit 1
fi
sort live_queue.raw > live_queue.txt
echo "live merge-queue refs: $(wc -l < live_queue.txt)"
fi

# Cache-holding PR numbers -> state, resolved once each. A PR that
# 404s (deleted fork, bad ref) is treated as OPEN: this janitor must
# never delete on missing evidence. On a PR-close run only the PR
# that closed is in scope, so resolve just that one rather than
# every PR holding a cache.
if [ -n "$CLOSED_PR" ]; then
prs="$CLOSED_PR"
else
prs="$(cut -f2 caches.tsv | sed -n 's#^refs/pull/\([0-9][0-9]*\)/.*#\1#p' | sort -un)"
fi
: > pr_state.txt
for pr in $prs; do
state="$(gh api "/repos/$REPO/pulls/$pr" --jq '.state' 2>/dev/null || echo open)"
printf '%s\t%s\n' "$pr" "$state" >> pr_state.txt
done

cutoff=$(date -u -d "$TAG_CACHE_MAX_AGE_DAYS days ago" +%s)
deleted=0
freed=0
kept=0
verb="DELETE"
[ "$DRY_RUN" = "true" ] && verb="WOULD DELETE"

while IFS=$'\t' read -r id ref size created key; do
reason=""

# A PR-close run is scoped to that one PR — the queue and tag
# scopes belong to the nightly sweep, which has the live-ref
# evidence a PR-close run deliberately skips fetching.
case "$ref" in
refs/heads/gh-readonly-queue/*)
# The queue branch is gone -> no run can ever check it out, so
# no run can ever restore this entry.
if [ -z "$CLOSED_PR" ] && ! grep -qxF "${ref#refs/heads/}" live_queue.txt; then
reason="merge-queue ref no longer exists"
fi
;;
refs/pull/*)
pr="$(printf '%s' "$ref" | sed -n 's#^refs/pull/\([0-9][0-9]*\)/.*#\1#p')"
if [ -n "$CLOSED_PR" ] && [ "$pr" != "$CLOSED_PR" ]; then
: # PR-close run: leave every other PR alone.
elif [ "$(awk -F'\t' -v p="$pr" '$1==p {print $2}' pr_state.txt)" = "closed" ]; then
reason="PR #$pr is closed"
fi
;;
*refs/tags/*)
# Tag refs are single-use: the next release is a different ref
# and cannot read these. Keep briefly for same-tag re-runs.
if [ -z "$CLOSED_PR" ] && [ "$(date -u -d "$created" +%s)" -lt "$cutoff" ]; then
reason="tag cache older than ${TAG_CACHE_MAX_AGE_DAYS}d"
fi
;;
esac

if [ -z "$reason" ]; then
kept=$((kept + 1))
continue
fi

printf '%s %6sMB %s (%s)\n' \
"$verb" "$((size / 1048576))" "$key" "$reason"
if [ "$DRY_RUN" != "true" ]; then
# Only a 404 means the entry is genuinely gone. Swallowing every
# failure would report a 403 (no `actions: write`) or a secondary
# rate-limit as a clean sweep, and the job would exit green
# having reclaimed nothing. `</dev/null` keeps gh off the loop's
# stdin; `2>&1 >/dev/null` captures stderr and drops stdout.
if ! err="$(gh api -X DELETE "/repos/$REPO/actions/caches/$id" </dev/null 2>&1 >/dev/null)"; then
case "$err" in
*"HTTP 404"*) echo " (already gone)" ;;
*) echo "::warning::delete failed for cache $id: $err" ;;
esac
continue
fi
fi
deleted=$((deleted + 1))
freed=$((freed + size))
done < caches.tsv

# `${DRY_RUN:+...}` would expand on every run — DRY_RUN is the string
# "false" on scheduled and PR-close runs, not empty.
would=""
[ "$DRY_RUN" = "true" ] && would="would have "
echo
echo "kept $kept, ${would}deleted $deleted entries, freeing $(awk -v b="$freed" 'BEGIN {printf "%.2f", b/1073741824}') GB"

- name: Report remaining usage
if: ${{ always() }}
run: |
gh api "/repos/$REPO/actions/cache/usage" \
--jq '"after sweep: \(.active_caches_count) entries, \((.active_caches_size_in_bytes / 1073741824) * 100 | round / 100) GB of the 10 GB quota"'
24 changes: 24 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ jobs:
target: wasm32-unknown-unknown
cache-shared-key: rust-debug

- uses: mozilla-actions/sccache-action@v0.0.10

- name: Enable sccache
run: |
echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV"
echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV"
echo "CARGO_INCREMENTAL=0" >> "$GITHUB_ENV"

- name: Check formatting
run: cargo fmt --all -- --check

Expand Down Expand Up @@ -140,6 +148,14 @@ jobs:
target: wasm32-unknown-unknown
cache-shared-key: rust-debug

- uses: mozilla-actions/sccache-action@v0.0.10

- name: Enable sccache
run: |
echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV"
echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV"
echo "CARGO_INCREMENTAL=0" >> "$GITHUB_ENV"

- uses: taiki-e/install-action@v2
with:
tool: cargo-nextest
Expand Down Expand Up @@ -167,6 +183,14 @@ jobs:
target: wasm32-unknown-unknown
cache-shared-key: rust-debug

- uses: mozilla-actions/sccache-action@v0.0.10

- name: Enable sccache
run: |
echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV"
echo "RUSTC_WRAPPER=sccache" >> "$GITHUB_ENV"
echo "CARGO_INCREMENTAL=0" >> "$GITHUB_ENV"

- name: Set cache keys
id: cache-keys
run: echo "week=$(date +%Y-W%V)" >> "$GITHUB_OUTPUT"
Expand Down
Loading
Loading