From 25925fa0068c5adc7adace6059a480cf30680fa1 Mon Sep 17 00:00:00 2001 From: dylan Date: Fri, 24 Jul 2026 02:43:24 +0000 Subject: [PATCH 1/3] feat: add local observability stack tooling --- .gitignore | 4 + Makefile | 8 +- README.md | 88 +++++++++++++++++++ dev.docker-compose.yml | 19 +++- monitoring/.env.observability.example | 4 + monitoring/docker-compose.yml | 41 +++++++++ .../provisioning/datasources/prometheus.yml | 10 +++ monitoring/prometheus.tmpl.yml | 13 +++ 8 files changed, 184 insertions(+), 3 deletions(-) create mode 100644 monitoring/.env.observability.example create mode 100644 monitoring/docker-compose.yml create mode 100644 monitoring/grafana/provisioning/datasources/prometheus.yml create mode 100644 monitoring/prometheus.tmpl.yml diff --git a/.gitignore b/.gitignore index 19b438f..294cd70 100644 --- a/.gitignore +++ b/.gitignore @@ -7,10 +7,14 @@ vendor/ bin/* *.env *.env* +!monitoring/.env.observability.example # Frontend app/coverage/ +# Local build output +tools/smoke/target/ + # Import script and temp data scripts/* allprintings.csv.zip diff --git a/Makefile b/Makefile index d99d77f..89b01fc 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build dev persistence clean test test-api test-smoke-rust deploy-ui deploy-server docker-server docker-ui docker +.PHONY: build dev persistence clean test test-api test-smoke-rust monitoring-up monitoring-down deploy-ui deploy-server docker-server docker-ui docker GOCMD=go GOBUILD=$(GOCMD) build @@ -68,5 +68,11 @@ import-csv: persistence: docker-compose -f dev.docker-compose.yml up -d postgres +monitoring-up: + cd monitoring && docker compose --env-file .env.observability up -d + +monitoring-down: + cd monitoring && docker compose --env-file .env.observability down + confirm: @echo -n "Are you sure? [y/N] " && read ans && [ $${ans:-N} = y ] diff --git a/README.md b/README.md index bd04e40..094b8a7 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,94 @@ The server emits structured JSON logs to stdout. LOG_LEVEL=debug make run ``` +For local stack runs via `dev.docker-compose.yml`, add basic container log controls: + +- `web`, `server`, and `postgres` now use the `json-file` logging driver. +- log rotation is set to `10m` per file with `3` files retained. + +Read logs per service: + +```sh +docker compose -f dev.docker-compose.yml logs -f server +docker compose -f dev.docker-compose.yml logs -f web +docker compose -f dev.docker-compose.yml logs -f postgres +``` + +## Observability stack (local) + +Use this stack to validate that the API is exporting `/prometheus` and that Grafana is wired to it. + +1. Ensure API metrics are enabled and token-protected in the API env. + +```sh +cd /root/.openclaw/workspace/vedh +sed -i 's/^METRICS_ENABLED=.*/METRICS_ENABLED=true/' .vedh.env +sed -i 's/^METRICS_TOKEN=.*/METRICS_TOKEN=dev-metrics-token/' .vedh.env +grep -q '^METRICS_ENABLED=' .vedh.env || echo 'METRICS_ENABLED=true' >> .vedh.env +grep -q '^METRICS_TOKEN=' .vedh.env || echo 'METRICS_TOKEN=dev-metrics-token' >> .vedh.env +``` + +2. Configure observability env. + +```sh +cd /root/.openclaw/workspace/vedh/monitoring +cp .env.observability.example .env.observability +``` + +Set these values in `.env.observability`: + +- `PROMETHEUS_TARGET` to the API host:port your monitoring stack should scrape + - default example: `host.docker.internal:8081` for the API in `dev.docker-compose.yml` +- `PROMETHEUS_BEARER_TOKEN` to match `METRICS_TOKEN` from `.vedh.env` +- `GRAFANA_ADMIN_USER` / `GRAFANA_ADMIN_PASSWORD` for UI login + +3. Start the stack. + +```sh +cd /root/.openclaw/workspace/vedh +make monitoring-up +``` + +If the API stack is already running, restart it after changing metrics env vars: + +```sh +docker compose -f dev.docker-compose.yml up -d --force-recreate server +``` + +4. Verify Prometheus is running and scraping the API. + +```sh +curl -sf http://localhost:9090/-/ready +curl -sf http://localhost:9090/api/v1/targets?state=any | grep -q '"job":"vedh-api"' && echo "scrape target configured" +curl -sf "http://localhost:9090/api/v1/query?query=up%7Bjob%3D%22vedh-api%22%7D" | grep -q '"value":\[' && echo "metrics query returned" +``` + +5. Verify Grafana has the Prometheus datasource and is connected. + +```sh +export GRAFANA_ADMIN_USER=admin +export GRAFANA_ADMIN_PASSWORD=admin + +curl -s -u "$GRAFANA_ADMIN_USER:$GRAFANA_ADMIN_PASSWORD" \ + http://localhost:3000/api/health | grep -q '"database": "ok"' && echo "grafana up" + +curl -s -u "$GRAFANA_ADMIN_USER:$GRAFANA_ADMIN_PASSWORD" \ + "http://localhost:3000/api/datasources/name/Prometheus" | grep -q '"url":"http://prometheus:9090"' && echo "prometheus datasource configured" +``` + +Open `http://localhost:3000` and run an Explore query such as: + +```txt +up{job="vedh-api"} +``` + +Stop the monitoring stack when done: + +```sh +cd /root/.openclaw/workspace/vedh +make monitoring-down +``` + ## Stack - Postgres stores application and card data. diff --git a/dev.docker-compose.yml b/dev.docker-compose.yml index d0f568f..7877ca2 100644 --- a/dev.docker-compose.yml +++ b/dev.docker-compose.yml @@ -1,18 +1,23 @@ services: web: build: - dockerfile: ./app/Dockerfile context: ./app + dockerfile: Dockerfile links: - server:server ports: - 8080:80 + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" labels: - "com.centurylinklabs.watchtower.enable=true" server: build: - dockerfile: ./Dockerfile context: . + dockerfile: Dockerfile env_file: - .vedh.env ports: @@ -23,6 +28,11 @@ services: - "./persistence/migrations/:/app/persistence/migrations/" depends_on: - postgres + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" labels: - "com.centurylinklabs.watchtower.enable=true" postgres: @@ -37,5 +47,10 @@ services: - ./.pg.env ports: - 5432:5432 + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" volumes: postgres-data: diff --git a/monitoring/.env.observability.example b/monitoring/.env.observability.example new file mode 100644 index 0000000..81ff6fd --- /dev/null +++ b/monitoring/.env.observability.example @@ -0,0 +1,4 @@ +PROMETHEUS_TARGET=host.docker.internal:8081 +PROMETHEUS_BEARER_TOKEN=change-me +GRAFANA_ADMIN_USER=admin +GRAFANA_ADMIN_PASSWORD=admin diff --git a/monitoring/docker-compose.yml b/monitoring/docker-compose.yml new file mode 100644 index 0000000..a0c5162 --- /dev/null +++ b/monitoring/docker-compose.yml @@ -0,0 +1,41 @@ +services: + prometheus: + image: prom/prometheus:v2.54.1 + ports: + - "9090:9090" + volumes: + - "./prometheus.tmpl.yml:/etc/prometheus/prometheus.tmpl.yml:ro" + - "prometheus_data:/prometheus" + command: + - /bin/sh + - -ec + - > + sed -e "s#\\$\\{PROMETHEUS_TARGET\\}#${PROMETHEUS_TARGET:-host.docker.internal:8081}#g" + -e "s#\\$\\{PROMETHEUS_BEARER_TOKEN\\}#${PROMETHEUS_BEARER_TOKEN}#g" + /etc/prometheus/prometheus.tmpl.yml > /etc/prometheus/prometheus.yml && + exec /bin/prometheus --config.file=/etc/prometheus/prometheus.yml --storage.tsdb.path=/prometheus --web.enable-lifecycle + environment: + - PROMETHEUS_TARGET=${PROMETHEUS_TARGET:-host.docker.internal:8081} + - PROMETHEUS_BEARER_TOKEN=${PROMETHEUS_BEARER_TOKEN:-} + restart: unless-stopped + extra_hosts: + - "host.docker.internal:host-gateway" + + grafana: + image: grafana/grafana:11.2.0 + ports: + - "3000:3000" + volumes: + - "./grafana/provisioning:/etc/grafana/provisioning:ro" + - "grafana_data:/var/lib/grafana" + env_file: + - .env.observability + depends_on: + - prometheus + restart: unless-stopped + extra_hosts: + - "host.docker.internal:host-gateway" + +volumes: + prometheus_data: + grafana_data: diff --git a/monitoring/grafana/provisioning/datasources/prometheus.yml b/monitoring/grafana/provisioning/datasources/prometheus.yml new file mode 100644 index 0000000..0e2cc8c --- /dev/null +++ b/monitoring/grafana/provisioning/datasources/prometheus.yml @@ -0,0 +1,10 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + uid: vedh-prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: false diff --git a/monitoring/prometheus.tmpl.yml b/monitoring/prometheus.tmpl.yml new file mode 100644 index 0000000..186b64e --- /dev/null +++ b/monitoring/prometheus.tmpl.yml @@ -0,0 +1,13 @@ +global: + scrape_interval: 15s + scrape_timeout: 10s + +scrape_configs: + - job_name: "vedh-api" + metrics_path: /prometheus + static_configs: + - targets: + - "${PROMETHEUS_TARGET}" + authorization: + type: Bearer + credentials: "${PROMETHEUS_BEARER_TOKEN}" From 8c12be90df96a8c01ced805c56feae99ef95aa58 Mon Sep 17 00:00:00 2001 From: dylan Date: Fri, 24 Jul 2026 02:43:29 +0000 Subject: [PATCH 2/3] style: polish vedh board and score views --- app/src/components/Card.vue | 97 ++++++++++-- app/src/views/BoardView.vue | 283 ++++++++++++++++++++++++++---------- app/src/views/ScoreView.vue | 99 +++++++++++-- 3 files changed, 384 insertions(+), 95 deletions(-) diff --git a/app/src/components/Card.vue b/app/src/components/Card.vue index 1331915..fea4d2d 100644 --- a/app/src/components/Card.vue +++ b/app/src/components/Card.vue @@ -125,37 +125,91 @@ function onImgError(ev: Event) { .card-tile { display: grid; gap: 0.35rem; - background: rgba(255, 255, 255, 0.06); - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 8px; + background: linear-gradient(180deg, rgba(255, 244, 237, 0.08), rgba(26, 22, 21, 0.34)); + border: 1px solid rgba(255, 244, 237, 0.11); + border-radius: 14px; padding: 0.4rem; cursor: grab; position: relative; - transition: transform 140ms ease, box-shadow 160ms ease; + overflow: hidden; + box-shadow: 0 10px 24px rgba(21, 12, 9, 0.18); + transition: transform 140ms ease, box-shadow 160ms ease, border-color 140ms ease, background 160ms ease; } .card-tile:active { cursor: grabbing; } -.card-tile .label { font-size: 0.8rem; opacity: 0.9; } +.card-tile .label { + font-size: 0.8rem; + opacity: 0.95; + line-height: 1.25; + font-weight: 500; + color: var(--vedh-text); + text-wrap: balance; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + min-height: 2em; +} .media { position: relative; } + +.media::after { + content: ''; + position: absolute; + inset: auto 0 0 0; + height: 34%; + background: linear-gradient(180deg, rgba(0,0,0,0), rgba(18, 12, 10, 0.38)); + pointer-events: none; +} + .media.image .image, .media.thumb .image { width: 100%; aspect-ratio: 0.714; object-fit: cover; - border-radius: 6px; + border-radius: 10px; + box-shadow: 0 8px 18px rgba(0, 0, 0, 0.28); + transition: transform 180ms ease, filter 180ms ease; } .media .image.placeholder { background: linear-gradient(135deg, rgba(40,40,40,0.9), rgba(22,22,22,0.9)); color: rgba(255,255,255,0.7); display: grid; place-items: center; + min-height: 100%; } /* tapped rotates the card image only */ -.tapped .media .image { transform: rotate(90deg); } +.tapped .media .image { + transform: rotate(90deg) scale(0.92); + transform-origin: center; +} + +.tapped::before, +.facedown::before { + content: attr(data-state); + position: absolute; + top: 0.7rem; + right: 0.7rem; + z-index: 3; + padding: 0.2rem 0.42rem; + border-radius: 999px; + font-size: 0.64rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--vedh-text); + background: rgba(17, 13, 12, 0.72); + border: 1px solid rgba(255, 244, 237, 0.16); +} + +.tapped { --card-state-label: 'Tapped'; } +.facedown { --card-state-label: 'Face down'; } + +.tapped::before { content: 'Tapped'; } +.facedown::before { content: 'Face down'; } .overlays { position: absolute; @@ -215,7 +269,23 @@ function onImgError(ev: Event) { } .card-tile:hover { transform: translateY(-4px); - box-shadow: 0 8px 22px rgba(0,0,0,0.55); + box-shadow: 0 16px 32px rgba(17, 10, 9, 0.36), 0 0 0 1px rgba(var(--vedh-primary-rgb), 0.12); + border-color: rgba(var(--vedh-primary-rgb), 0.28); +} + +.card-tile:hover .image { + transform: scale(1.03); + filter: saturate(1.04) contrast(1.02); +} + +.selected { + border-color: rgba(var(--vedh-secondary-rgb), 0.55); + box-shadow: 0 0 0 1px rgba(var(--vedh-secondary-rgb), 0.2), 0 12px 28px rgba(21, 12, 9, 0.22); +} + +.highlight { + border-color: rgba(var(--vedh-primary-rgb), 0.58); + box-shadow: 0 0 0 1px rgba(var(--vedh-primary-rgb), 0.22), 0 0 24px rgba(var(--vedh-primary-rgb), 0.16); } /* sizes: adjust text and padding, image will fill width of grid cell */ @@ -223,4 +293,13 @@ function onImgError(ev: Event) { .size-sm { padding: 0.35rem; } .size-md { padding: 0.4rem; } .size-lg { padding: 0.5rem; } - \ No newline at end of file + +.size-xs .label, +.size-sm .label { + font-size: 0.72rem; +} + +.size-lg .label { + font-size: 0.86rem; +} + diff --git a/app/src/views/BoardView.vue b/app/src/views/BoardView.vue index 9392fd4..a4b22cd 100644 --- a/app/src/views/BoardView.vue +++ b/app/src/views/BoardView.vue @@ -66,10 +66,17 @@

{{ player.Username }}

{{ player.Boardstate?.Life ?? '—' }} life +
+ {{ isActivePlayer(player.Username) ? 'Active turn' : 'Waiting' }} + BF {{ player.Boardstate?.Battlefield?.length ?? 0 }} + Hand {{ player.Boardstate?.Hand?.length ?? 0 }} + GY {{ player.Boardstate?.Graveyard?.length ?? 0 }} + Library {{ player.Boardstate?.Library?.length ?? 0 }} +

Commander -

@@ -92,7 +99,7 @@

Battlefield -

@@ -121,7 +128,7 @@

Graveyard ({{ player.Boardstate?.Graveyard?.length ?? 0 }}) -

@@ -144,7 +151,7 @@

Exiled ({{ player.Boardstate?.Exiled?.length ?? 0 }}) -

@@ -167,7 +174,7 @@

Revealed ({{ player.Boardstate?.Revealed?.length ?? 0 }}) -

@@ -190,7 +197,7 @@

Controlled ({{ player.Boardstate?.Controlled?.length ?? 0 }}) -

@@ -296,7 +303,7 @@

Commander -

@@ -344,7 +351,7 @@

Battlefield -

@@ -390,7 +397,7 @@

Hand ({{ selfPlayer.Boardstate?.Hand?.length ?? 0 }}) -

@@ -436,7 +443,7 @@

Graveyard ({{ selfPlayer.Boardstate?.Graveyard?.length ?? 0 }}) -

@@ -482,7 +489,7 @@

Exiled ({{ selfPlayer.Boardstate?.Exiled?.length ?? 0 }}) -

@@ -528,7 +535,7 @@

Revealed ({{ selfPlayer.Boardstate?.Revealed?.length ?? 0 }}) -

@@ -574,7 +581,7 @@

Controlled ({{ selfPlayer.Boardstate?.Controlled?.length ?? 0 }}) -

@@ -1846,14 +1853,22 @@ watch(stackedZones, (val) => { gap: 1rem; height: 100dvh; --main-player-height: 33vh; /* bottom third reserved for player's control center */ - --turn-accent: #f5b342; + --turn-accent: var(--vedh-primary); + --zone-gap: 0.75rem; + --row-tile-width: 132px; + --row-art-width: 150px; + --secondary-tile-width: 106px; + --secondary-art-width: 124px; + color: var(--vedh-text); } .board-header { - background: rgba(255, 255, 255, 0.05); - border-radius: 16px; + background: rgba(var(--vedh-bg-rgb), 0.72); + backdrop-filter: blur(16px); + border-radius: 18px; padding: 1rem 1.25rem; - border: 1px solid rgba(255, 255, 255, 0.08); + border: 1px solid var(--vedh-border); + box-shadow: 0 18px 42px rgba(21, 12, 9, 0.28); display: flex; align-items: center; justify-content: space-between; @@ -1872,9 +1887,9 @@ watch(stackedZones, (val) => { gap: 0.75rem; padding: 0.5rem 0.75rem; border-radius: 14px; - background: linear-gradient(135deg, rgba(255, 255, 255, 0.18), rgba(255, 255, 255, 0.02)); - border: 1px solid rgba(255, 255, 255, 0.22); - box-shadow: 0 14px 28px rgba(0, 0, 0, 0.32), inset 0 0 0 1px rgba(255, 255, 255, 0.05); + background: linear-gradient(135deg, rgba(var(--vedh-primary-rgb), 0.18), rgba(var(--vedh-secondary-rgb), 0.08)); + border: 1px solid rgba(255, 244, 237, 0.18); + box-shadow: 0 14px 28px rgba(21, 12, 9, 0.32), inset 0 0 0 1px rgba(255, 244, 237, 0.05); transition: box-shadow 0.25s ease, border-color 0.25s ease, background 0.25s ease; } @@ -1906,12 +1921,12 @@ watch(stackedZones, (val) => { } .turn-spotlight.priority-owner { - border-color: rgba(245, 179, 66, 0.6); - background: linear-gradient(135deg, rgba(245, 179, 66, 0.22), rgba(255, 255, 255, 0.04)); + border-color: rgba(var(--vedh-primary-rgb), 0.6); + background: linear-gradient(135deg, rgba(var(--vedh-primary-rgb), 0.28), rgba(var(--vedh-secondary-rgb), 0.12)); box-shadow: - 0 14px 28px rgba(0, 0, 0, 0.35), - 0 0 18px rgba(245, 179, 66, 0.35), - 0 0 36px rgba(245, 179, 66, 0.2); + 0 14px 28px rgba(21, 12, 9, 0.35), + 0 0 18px rgba(var(--vedh-primary-rgb), 0.35), + 0 0 36px rgba(var(--vedh-secondary-rgb), 0.18); } .turn-meta { @@ -1933,9 +1948,9 @@ watch(stackedZones, (val) => { text-transform: uppercase; padding: 0.25rem 0.5rem; border-radius: 999px; - background: rgba(255,255,255,0.06); - border: 1px solid rgba(255,255,255,0.1); - color: rgba(255,255,255,0.8); + background: rgba(255,244,237,0.07); + border: 1px solid var(--vedh-border); + color: var(--vedh-muted); } .turn-controls { @@ -1964,9 +1979,9 @@ watch(stackedZones, (val) => { .settings-trigger { appearance: none; - border: 1px solid rgba(255, 255, 255, 0.12); - background: rgba(255, 255, 255, 0.06); - color: #fff; + border: 1px solid var(--vedh-border); + background: rgba(255, 244, 237, 0.08); + color: var(--vedh-text); font-size: 1rem; padding: 0.35rem 0.6rem; border-radius: 10px; @@ -1977,8 +1992,8 @@ watch(stackedZones, (val) => { position: absolute; right: 0; top: calc(100% + 0.5rem); - background: rgba(18, 18, 18, 0.98); - border: 1px solid rgba(255,255,255,0.12); + background: var(--vedh-panel-strong); + border: 1px solid var(--vedh-border); border-radius: 12px; min-width: 220px; padding: 0.75rem 0.9rem; @@ -2065,31 +2080,63 @@ watch(stackedZones, (val) => { .players { display: grid; - gap: 0.75rem; + gap: var(--zone-gap); } .players article { display: grid; grid-template-columns: repeat(6, minmax(140px, 1fr)); - gap: 0.75rem; + gap: var(--zone-gap); align-items: flex-start; overflow-x: auto; } .players article { - background: rgba(255, 255, 255, 0.04); - border-radius: 14px; + background: var(--vedh-panel); + border-radius: 16px; padding: 0.75rem 1rem; - border: 1px solid rgba(255, 255, 255, 0.08); + border: 1px solid var(--vedh-border); + box-shadow: 0 12px 30px rgba(21, 12, 9, 0.16); } .players article.active { - border-color: rgba(133, 215, 255, 0.6); - box-shadow: 0 0 0 1px rgba(133, 215, 255, 0.15); + border-color: rgba(var(--vedh-primary-rgb), 0.55); + box-shadow: 0 0 0 1px rgba(var(--vedh-primary-rgb), 0.15), 0 0 24px rgba(var(--vedh-secondary-rgb), 0.12); } .players article > header { grid-column: 1 / -1; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.players article > header h2 { + margin: 0; +} + +.player-summary { + grid-column: 1 / -1; + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin-top: -0.2rem; +} + +.summary-chip { + border-radius: 999px; + padding: 0.22rem 0.55rem; + font-size: 0.72rem; + letter-spacing: 0.03em; + background: rgba(255, 244, 237, 0.07); + border: 1px solid rgba(255, 244, 237, 0.14); + color: rgba(255, 244, 237, 0.84); +} + +.summary-chip.hot { + border-color: rgba(var(--vedh-primary-rgb), 0.68); + box-shadow: 0 0 0 1px rgba(var(--vedh-primary-rgb), 0.18), 0 0 16px rgba(var(--vedh-secondary-rgb), 0.16); } .players article .zone { @@ -2113,10 +2160,10 @@ watch(stackedZones, (val) => { .zone { margin-top: 0.5rem; - border: 1px solid rgba(255,255,255,0.04); - background: rgba(0,0,0,0.02); - padding: 0.5rem; - border-radius: 8px; + border: 1px solid rgba(255,244,237,0.07); + background: linear-gradient(180deg, rgba(255,244,237,0.04), rgba(0,0,0,0.06)); + padding: 0.65rem; + border-radius: 12px; position: relative; transition: box-shadow 140ms ease, border-color 120ms ease; } @@ -2135,13 +2182,13 @@ watch(stackedZones, (val) => { transition: background 160ms ease, opacity 160ms ease, transform 160ms ease; } .zone.drag-over::before { - background: linear-gradient(90deg, rgba(133,215,255,0.95), rgba(80,180,255,0.85)); + background: linear-gradient(90deg, rgba(var(--vedh-primary-rgb),0.95), rgba(var(--vedh-secondary-rgb),0.85)); opacity: 1; transform: scaleX(1); } .zone.drag-over { - border-color: rgba(80,180,255,0.9); - box-shadow: 0 12px 36px rgba(6,20,30,0.6); + border-color: rgba(var(--vedh-primary-rgb),0.85); + box-shadow: 0 12px 36px rgba(33,20,18,0.45), 0 0 24px rgba(var(--vedh-secondary-rgb),0.18); } .zone.zone-hit { @@ -2150,10 +2197,14 @@ watch(stackedZones, (val) => { .zone h3 { margin: 0 0 0.25rem; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.35rem; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.08em; - color: rgba(255, 255, 255, 0.65); + color: rgba(255, 244, 237, 0.72); } .zone h3 small { @@ -2199,24 +2250,29 @@ watch(stackedZones, (val) => { overflow-x: auto; overflow-y: hidden; padding-bottom: 0.25rem; + scroll-snap-type: x proximity; } .zone[data-zone='Battlefield'] .cards.tiles > *, .zone[data-zone='Hand'] .cards.tiles > *, .zone[data-zone='Battlefield'] .cards.art > *, .zone[data-zone='Hand'] .cards.art > * { - flex: 0 0 120px; + flex: 0 0 var(--row-tile-width); + scroll-snap-align: start; } .zone[data-zone='Battlefield'] .cards.art > *, .zone[data-zone='Hand'] .cards.art > * { - flex-basis: 140px; + flex-basis: var(--row-art-width); +} + +/* Keep secondary zones compact and denser so the board scans faster */ +.zone:not([data-zone='Battlefield']):not([data-zone='Hand']) .cards.tiles { + grid-template-columns: repeat(auto-fill, minmax(var(--secondary-tile-width), 1fr)); } -/* Keep secondary zones in a row with vertical card stacks */ -.zone:not([data-zone='Battlefield']):not([data-zone='Hand']) .cards.tiles, .zone:not([data-zone='Battlefield']):not([data-zone='Hand']) .cards.art { - grid-template-columns: 1fr; + grid-template-columns: repeat(auto-fill, minmax(var(--secondary-art-width), 1fr)); } /* Allow zone lists to expand vertically to fit their cards */ @@ -2227,38 +2283,75 @@ watch(stackedZones, (val) => { .card-tile { display: grid; gap: 0.35rem; - background: rgba(255, 255, 255, 0.06); - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 8px; + background: linear-gradient(180deg, rgba(255, 244, 237, 0.08), rgba(26, 22, 21, 0.34)); + border: 1px solid rgba(255, 244, 237, 0.11); + border-radius: 12px; padding: 0.4rem; cursor: grab; + overflow: hidden; + box-shadow: 0 10px 24px rgba(21, 12, 9, 0.18); } .card-tile img { width: 100%; aspect-ratio: 0.714; /* 63x88mm ratio */ object-fit: cover; - border-radius: 6px; + border-radius: 10px; + box-shadow: 0 8px 18px rgba(0, 0, 0, 0.28); + transition: transform 180ms ease, filter 180ms ease; +} +.card-tile .label { + font-size: 0.8rem; + opacity: 0.95; + line-height: 1.24; + font-weight: 500; + color: var(--vedh-text); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + min-height: 2em; +} + +.zone[data-zone='Commander'] .card-tile { + border-color: rgba(var(--vedh-primary-rgb), 0.28); + box-shadow: 0 12px 28px rgba(21, 12, 9, 0.22), 0 0 0 1px rgba(var(--vedh-primary-rgb), 0.08); +} + +.zone[data-zone='Commander'] .card-tile .label { + font-weight: 600; +} + +.zone[data-zone='Battlefield'] .card-tile .label, +.zone[data-zone='Hand'] .card-tile .label { + font-size: 0.74rem; +} + +.zone[data-zone='Graveyard'] .card-tile, +.zone[data-zone='Exiled'] .card-tile, +.zone[data-zone='Revealed'] .card-tile, +.zone[data-zone='Controlled'] .card-tile { + padding: 0.34rem; } -.card-tile .label { font-size: 0.8rem; opacity: 0.9; } .stack-card { position: relative; + border-color: rgba(var(--vedh-secondary-rgb), 0.24); } .stack-resolve { position: absolute; top: 6px; right: 6px; z-index: 2; - border: 1px solid rgba(255, 255, 255, 0.2); - background: rgba(20, 20, 20, 0.85); - color: #fff; + border: 1px solid rgba(var(--vedh-primary-rgb), 0.3); + background: rgba(var(--vedh-bg-rgb), 0.9); + color: var(--vedh-text); font-size: 0.7rem; padding: 0.2rem 0.4rem; border-radius: 6px; cursor: pointer; } .stack-resolve:hover { - background: rgba(40, 40, 40, 0.9); + background: rgba(84, 75, 71, 0.95); } /* Pulsing glow when dragging or on hover */ @@ -2290,7 +2383,13 @@ watch(stackedZones, (val) => { } .card-tile:hover { transform: translateY(-4px); - box-shadow: 0 8px 22px rgba(0,0,0,0.55); + box-shadow: 0 16px 32px rgba(17, 10, 9, 0.36), 0 0 0 1px rgba(var(--vedh-primary-rgb), 0.12); + border-color: rgba(var(--vedh-primary-rgb), 0.28); +} + +.card-tile:hover img { + transform: scale(1.03); + filter: saturate(1.04) contrast(1.02); } /* Stacks view */ @@ -2367,15 +2466,16 @@ watch(stackedZones, (val) => { } .stack { - background: rgba(255, 255, 255, 0.04); - border-radius: 14px; - border: 1px solid rgba(255, 255, 255, 0.08); + background: var(--vedh-panel); + border-radius: 18px; + border: 1px solid var(--vedh-border); padding: 0.75rem 1rem; display: grid; gap: 0.5rem; position: sticky; top: 0.75rem; z-index: 5; + box-shadow: 0 16px 34px rgba(21, 12, 9, 0.2); } @keyframes stack-pop { @@ -2398,8 +2498,8 @@ watch(stackedZones, (val) => { bottom: 0; z-index: 999; height: var(--main-player-height); - background: linear-gradient(180deg, rgba(24,24,24,0.98) 0%, rgba(12,12,12,0.98) 100%); - border-top: 4px solid rgba(255,255,255,0.06); /* sharp dividing line */ + background: linear-gradient(180deg, rgba(84,75,71,0.98) 0%, rgba(47,41,39,0.98) 100%); + border-top: 4px solid rgba(var(--vedh-primary-rgb),0.25); /* sharp dividing line */ padding: 0; border-radius: 0 0 0 0; box-shadow: 0 -14px 40px rgba(0,0,0,0.55); @@ -2486,22 +2586,22 @@ watch(stackedZones, (val) => { padding: 0.18rem 0.52rem; font-size: 0.72rem; letter-spacing: 0.03em; - background: rgba(255, 255, 255, 0.05); - border: 1px solid rgba(255, 255, 255, 0.12); - color: rgba(255, 255, 255, 0.82); + background: rgba(255, 244, 237, 0.07); + border: 1px solid rgba(255, 244, 237, 0.14); + color: rgba(255, 244, 237, 0.88); transition: border-color 130ms ease, box-shadow 150ms ease, transform 120ms ease; } .status-chip.hot { - border-color: rgba(133, 215, 255, 0.75); - box-shadow: 0 0 0 1px rgba(133, 215, 255, 0.22), 0 0 18px rgba(133, 215, 255, 0.3); + border-color: rgba(var(--vedh-primary-rgb), 0.75); + box-shadow: 0 0 0 1px rgba(var(--vedh-primary-rgb), 0.22), 0 0 18px rgba(var(--vedh-secondary-rgb), 0.2); transform: translateY(-1px); } .main-player-right { display: grid; grid-template-columns: repeat(6, minmax(130px, 1fr)); - gap: 0.75rem; + gap: var(--zone-gap); align-items: start; overflow-x: auto; } @@ -2609,14 +2709,43 @@ header .player-toolbar { .player-toolbar .tool { appearance: none; - border: 1px solid rgba(255, 255, 255, 0.12); - background: rgba(255, 255, 255, 0.06); - color: #fff; + border: 1px solid var(--vedh-border); + background: rgba(255, 244, 237, 0.08); + color: var(--vedh-text); font-size: 0.8rem; padding: 0.25rem 0.5rem; border-radius: 999px; } +.board .tool { + appearance: none; + border: 1px solid var(--vedh-border); + background: rgba(255, 244, 237, 0.08); + color: var(--vedh-text); + border-radius: 999px; + padding: 0.28rem 0.6rem; + font-size: 0.78rem; + line-height: 1.1; + box-shadow: 0 4px 12px rgba(21, 12, 9, 0.14); +} + +.board .tool:hover:not(:disabled) { + background: rgba(var(--vedh-primary-rgb), 0.16); + border-color: rgba(var(--vedh-primary-rgb), 0.38); +} + +.board .tool:disabled { + opacity: 0.5; + cursor: not-allowed; + box-shadow: none; +} + +.zone-toggle { + margin-left: auto; + font-size: 0.7rem; + padding: 0.15rem 0.5rem; +} + .board button { touch-action: manipulation; -webkit-tap-highlight-color: rgba(133, 215, 255, 0.3); diff --git a/app/src/views/ScoreView.vue b/app/src/views/ScoreView.vue index f85ac64..4a735db 100644 --- a/app/src/views/ScoreView.vue +++ b/app/src/views/ScoreView.vue @@ -6,8 +6,15 @@
-

{{ player.Username }}

-

{{ player.Boardstate?.Life ?? '—' }} life

+
+

{{ player.Username }}

+ {{ player.Boardstate?.Life ?? '—' }} life +
+
+ Battlefield {{ player.Boardstate?.Battlefield?.length ?? 0 }} + Hand {{ player.Boardstate?.Hand?.length ?? 0 }} + GY {{ player.Boardstate?.Graveyard?.length ?? 0 }} +

Commander damage

Coming soon in v2

@@ -18,11 +25,35 @@ From e023c17cc9d22b92569e4fe241525d43a5729af6 Mon Sep 17 00:00:00 2001 From: dylan Date: Fri, 24 Jul 2026 02:43:33 +0000 Subject: [PATCH 3/3] docs: add vedh NFT card tracking integration plan --- ...7-16-vedh-nft-card-tracking-integration.md | 908 ++++++++++++++++++ 1 file changed, 908 insertions(+) create mode 100644 docs/plans/2026-07-16-vedh-nft-card-tracking-integration.md diff --git a/docs/plans/2026-07-16-vedh-nft-card-tracking-integration.md b/docs/plans/2026-07-16-vedh-nft-card-tracking-integration.md new file mode 100644 index 0000000..5d66c14 --- /dev/null +++ b/docs/plans/2026-07-16-vedh-nft-card-tracking-integration.md @@ -0,0 +1,908 @@ +# vEDH NFT Card Tracking Integration Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add an optional NFT-backed custom card system to vEDH so users can verify ownership of custom trading cards, import them into their vEDH card library, and track them in games without turning vEDH into a marketplace or rules engine. + +**Architecture:** Keep gameplay state off-chain and keep vEDH as the canonical boardstate tracker. Add a wallet-link + ownership-verification layer, a custom-card metadata registry keyed by `chain_id + contract_address + token_id`, and a UI flow that lets users sync owned NFT cards into their personal library and use those cards in games. Ship EVM read-only ownership verification first; defer minting, listings, bidding, and cross-chain abstractions until the ownership loop is solid. + +**Tech Stack:** Go, gqlgen GraphQL, PostgreSQL migrations in `persistence/migrations`, Vue 3, Pinia, Apollo GraphQL, Vitest, Go integration tests, optional EVM JSON-RPC via `go-ethereum` on the backend and `viem` on the frontend. + +--- + +## Current grounded context + +The current codebase is a strong fit for an ownership-tracking integration, but not yet for NFT-native data: + +- vEDH is already a **format-agnostic boardstate tracker**, not a rules engine, per `docs/contextlog.md` +- auth is currently **username/password only** in: + - `server/users.go` + - `app/src/stores/auth.ts` +- GraphQL schema currently exposes: + - `signup`, `login` + - game creation/join/update + - card search/query endpoints + - no wallet, collection, or ownership types yet +- game state currently stores cards inline via the `Card` GraphQL type in `server/schema.graphql` +- board rendering is still heavily card-object driven in: + - `app/src/views/BoardView.vue` +- frontend already has a working auth store and game store in: + - `app/src/stores/auth.ts` + - `app/src/stores/games.ts` +- backend already has a pattern for adding feature-specific GraphQL resolvers: + - `server/formats.go` + - `server/formats_api.go` + - `server/formats_api_test.go` +- DB migrations run from `persistence/migrations` through `persistence/sql.go` + +That means the safest first slice is: + +1. add wallet linking to an existing vEDH account +2. verify NFT ownership off-chain via chain RPC reads +3. materialize owned custom cards into a vEDH-side card library +4. allow those cards to appear in vEDH board/deck flows + +Do **not** start with on-chain minting, marketplace flows, or game-state writes to chain. + +## Recommended product boundary + +### Ship in v1 + +- Link one or more wallets to a vEDH account +- Verify ownership for one supported chain family (**EVM only** first) +- Support approved NFT collections that represent custom trading cards +- Import owned tokens as usable custom cards inside vEDH +- Preserve NFT provenance in card metadata and game logs +- Allow cards to remain usable in an active game even if ownership changes mid-game, while marking ownership drift after resync + +### Explicitly do not ship in v1 + +- Minting from vEDH +- Marketplace listing/buy/sell/auction +- Trustless on-chain match state +- Cross-chain abstraction beyond EVM-compatible contracts +- Full arbitrary metadata editing in the browser +- Auto-enforcing card legality from the blockchain + +## Hard decisions to make up front + +These are real decisions, not filler. Block implementation at the first step if they remain fuzzy. + +1. **Chain family** + - Recommendation: EVM only first (`chain_id`, `contract_address`, `token_id`) +2. **Token standard support** + - Recommendation: support both ERC-721 and ERC-1155 reads, but start test coverage with ERC-721 first +3. **Collection policy** + - Recommendation: only whitelisted collections in v1; no arbitrary contract import +4. **Gameplay ownership rule** + - Recommendation: ownership checked on import and optional resync, not every board interaction +5. **Custom-card metadata source of truth** + - Recommendation: vEDH stores a normalized cached copy of NFT metadata plus a vEDH-specific play payload +6. **Auth model** + - Recommendation: keep username/password auth, add wallet linking as a secondary identity method + +--- + +### Task 1: Add the NFT domain model and migration scaffolding + +**Files:** +- Create: `persistence/migrations/000019_nft_card_tracking.up.sql` +- Create: `persistence/migrations/000019_nft_card_tracking.down.sql` +- Create: `server/nft_models.go` +- Modify: `server/schema.graphql` +- Test: `server/graphql_httpserver_test.go` + +**Step 1: Write the failing migration-oriented backend test** + +Add a backend/API test that expects the new tables to exist after test DB setup. + +Example assertion shape: + +```go +func TestNFTTablesExist(t *testing.T) { + db := testDB(t) + for _, table := range []string{ + "wallet_identities", + "nft_collections", + "nft_card_templates", + "nft_ownership_snapshots", + } { + var exists bool + err := db.QueryRow(` + SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_name = $1 + ) + `, table).Scan(&exists) + require.NoError(t, err) + require.True(t, exists, table) + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: + +```bash +cd /root/.openclaw/workspace/vedh +/usr/local/go/bin/go test ./server -run TestNFTTablesExist -v +``` + +Expected: FAIL because the schema does not exist yet. + +**Step 3: Write the minimal schema and model layer** + +Create migration tables for: + +- `wallet_identities` + - `id uuid primary key` + - `user_id uuid not null` + - `chain_id bigint not null` + - `address text not null` + - `verified_at timestamptz not null` + - `created_at timestamptz not null default now()` + - unique `(chain_id, address)` +- `wallet_link_challenges` + - `id uuid primary key` + - `user_id uuid not null` + - `chain_id bigint not null` + - `address text not null` + - `nonce text not null` + - `expires_at timestamptz not null` + - `used_at timestamptz` +- `nft_collections` + - `id uuid primary key` + - `chain_id bigint not null` + - `contract_address text not null` + - `name text not null` + - `symbol text` + - `token_standard text not null` + - `is_enabled boolean not null default true` + - unique `(chain_id, contract_address)` +- `nft_card_templates` + - `id uuid primary key` + - `collection_id uuid not null` + - `token_id text not null` + - `name text not null` + - `image_url text` + - `metadata_url text` + - `external_url text` + - `vedh_card_payload jsonb not null` + - `metadata_hash text` + - unique `(collection_id, token_id)` +- `nft_ownership_snapshots` + - `id uuid primary key` + - `wallet_identity_id uuid not null` + - `template_id uuid not null` + - `quantity numeric not null default 1` + - `owner_address text not null` + - `synced_at timestamptz not null` + - unique `(wallet_identity_id, template_id)` + +Create `server/nft_models.go` with Go structs that mirror these records. + +**Step 4: Run the targeted test** + +Run: + +```bash +cd /root/.openclaw/workspace/vedh +/usr/local/go/bin/go test ./server -run TestNFTTablesExist -v +``` + +Expected: PASS. + +**Step 5: Commit** + +```bash +git add persistence/migrations/000019_nft_card_tracking.* server/nft_models.go server/graphql_httpserver_test.go +git commit -m "feat: add NFT tracking schema scaffolding" +``` + +--- + +### Task 2: Add wallet-link challenge and verification flow + +**Files:** +- Create: `server/wallet_auth.go` +- Create: `server/wallet_auth_test.go` +- Modify: `server/schema.graphql` +- Modify: `server/generated.go` (via gqlgen) +- Modify: `server/users.go` +- Modify: `app/src/stores/auth.ts` +- Create: `app/src/services/wallet.ts` +- Test: `server/wallet_auth_test.go` + +**Step 1: Write the failing backend auth tests** + +Add tests for: + +- creating a wallet-link challenge for the authenticated user +- rejecting expired challenges +- rejecting reused nonces +- linking the wallet after a valid signature + +Example assertion shape: + +```go +func TestCreateWalletLinkChallenge(t *testing.T) { + s := testAPI(t) + challenge, err := s.CreateWalletLinkChallenge(authCtx("shakezula"), 1, "0xabc...") + require.NoError(t, err) + require.NotEmpty(t, challenge.Nonce) + require.True(t, challenge.ExpiresAt.After(time.Now())) +} +``` + +**Step 2: Run tests to verify they fail** + +Run: + +```bash +cd /root/.openclaw/workspace/vedh +/usr/local/go/bin/go test ./server -run 'TestCreateWalletLinkChallenge|TestLinkWallet' -v +``` + +Expected: FAIL because no wallet auth resolver exists. + +**Step 3: Add minimal schema and resolver support** + +Extend `server/schema.graphql` with types/mutations like: + +```graphql +type WalletIdentity { + ID: String! + ChainID: String! + Address: String! + VerifiedAt: Time! +} + +type WalletLinkChallenge { + Address: String! + ChainID: String! + Nonce: String! + Message: String! + ExpiresAt: Time! +} + +extend type Mutation { + createWalletLinkChallenge(chainID: String!, address: String!): WalletLinkChallenge! + linkWallet(chainID: String!, address: String!, signature: String!): WalletIdentity! +} +``` + +Implement backend behavior in `server/wallet_auth.go`: + +- require existing vEDH auth +- normalize address to lowercase checksum-insensitive storage key +- create short-lived nonce challenge +- verify EVM personal-sign/SIWE-style signature server-side +- persist `wallet_identities` +- invalidate used challenge + +Implement a tiny frontend helper in `app/src/services/wallet.ts` using `window.ethereum` first; do not add WalletConnect yet. + +**Step 4: Regenerate gqlgen** + +Run: + +```bash +cd /root/.openclaw/workspace/vedh +make generate +``` + +Expected: generated files update cleanly. + +**Step 5: Run backend tests** + +Run: + +```bash +cd /root/.openclaw/workspace/vedh +/usr/local/go/bin/go test ./server -run 'TestCreateWalletLinkChallenge|TestLinkWallet' -v +``` + +Expected: PASS. + +**Step 6: Commit** + +```bash +git add server/schema.graphql server/wallet_auth.go server/wallet_auth_test.go server/generated.go server/users.go app/src/stores/auth.ts app/src/services/wallet.ts +git commit -m "feat: add wallet linking flow" +``` + +--- + +### Task 3: Add approved NFT collection registry and EVM read provider + +**Files:** +- Create: `server/nft_provider.go` +- Create: `server/nft_provider_evm.go` +- Create: `server/nft_provider_evm_test.go` +- Create: `server/nft_collections.go` +- Modify: `server/schema.graphql` +- Test: `server/nft_provider_evm_test.go` + +**Step 1: Write the failing provider tests** + +Add tests for: + +- reading ERC-721 owner for a token +- reading ERC-1155 balance for a token +- rejecting an unsupported token standard +- normalizing contract addresses + +Example assertion shape: + +```go +func TestEVMProviderOwnerOf721(t *testing.T) { + provider := newMockEVMProvider(t) + owner, qty, err := provider.LookupOwnership(context.Background(), CollectionRef{ + ChainID: 1, + ContractAddress: "0x1234...", + TokenStandard: "ERC721", + }, "42", "0xabcd...") + require.NoError(t, err) + require.Equal(t, "0xabcd...", owner) + require.Equal(t, 1, qty) +} +``` + +**Step 2: Run tests to verify they fail** + +Run: + +```bash +cd /root/.openclaw/workspace/vedh +/usr/local/go/bin/go test ./server -run 'TestEVMProvider' -v +``` + +Expected: FAIL because provider layer does not exist. + +**Step 3: Implement minimal provider abstraction** + +Create interface in `server/nft_provider.go`: + +```go +type NFTProvider interface { + LookupOwnership(ctx context.Context, collection CollectionRef, tokenID string, address string) (owner string, quantity int, err error) + FetchMetadata(ctx context.Context, collection CollectionRef, tokenID string) (*NFTMetadata, error) +} +``` + +Implement `server/nft_provider_evm.go` with: + +- EVM RPC client +- ERC-721 `ownerOf` +- ERC-1155 `balanceOf` +- metadata URI fetch for whitelisted collections + +Create `server/nft_collections.go` for CRUD/lookup of approved collections. + +**Step 4: Run provider tests** + +Run: + +```bash +cd /root/.openclaw/workspace/vedh +/usr/local/go/bin/go test ./server -run 'TestEVMProvider' -v +``` + +Expected: PASS. + +**Step 5: Commit** + +```bash +git add server/nft_provider.go server/nft_provider_evm.go server/nft_provider_evm_test.go server/nft_collections.go +git commit -m "feat: add EVM NFT ownership provider" +``` + +--- + +### Task 4: Materialize NFT metadata into vEDH custom-card templates + +**Files:** +- Create: `server/nft_sync.go` +- Create: `server/nft_sync_test.go` +- Modify: `server/schema.graphql` +- Modify: `server/formats.go` (only if custom-card payload needs format helpers) +- Test: `server/nft_sync_test.go` + +**Step 1: Write the failing sync tests** + +Add tests for: + +- syncing a wallet-owned token creates a `nft_card_templates` record +- syncing again updates metadata instead of duplicating +- unowned token is removed or quantity zeroed in `nft_ownership_snapshots` +- malformed metadata is rejected cleanly + +**Step 2: Run tests to verify they fail** + +Run: + +```bash +cd /root/.openclaw/workspace/vedh +/usr/local/go/bin/go test ./server -run 'TestSyncNFTCards' -v +``` + +Expected: FAIL because no sync flow exists. + +**Step 3: Implement minimal sync behavior** + +Create mutation in schema: + +```graphql +extend type Mutation { + syncMyNFTCards: [NFTCard!]! +} +``` + +Add types: + +```graphql +type NFTCard { + ID: String! + ChainID: String! + ContractAddress: String! + TokenID: String! + Name: String! + ImageURL: String + MetadataURL: String + Quantity: Int! + OwnershipVerifiedAt: Time! +} +``` + +Implement `server/nft_sync.go` to: + +- load linked wallets for current user +- scan approved collections for owned token IDs +- fetch metadata +- normalize metadata into a vEDH payload (`name`, `image_url`, optional `types`, optional custom stats text) +- upsert `nft_card_templates` +- upsert `nft_ownership_snapshots` + +**Step 4: Run sync tests** + +Run: + +```bash +cd /root/.openclaw/workspace/vedh +/usr/local/go/bin/go test ./server -run 'TestSyncNFTCards' -v +``` + +Expected: PASS. + +**Step 5: Commit** + +```bash +git add server/schema.graphql server/nft_sync.go server/nft_sync_test.go +git commit -m "feat: sync owned NFT cards into vedh templates" +``` + +--- + +### Task 5: Expose NFT-backed cards through GraphQL queries and search + +**Files:** +- Modify: `server/schema.graphql` +- Modify: `server/formats_api.go` or create `server/nft_api.go` +- Modify: `server/formats_api_test.go` or create `server/nft_api_test.go` +- Modify: `app/src/graphql/queries.ts` +- Modify: `app/src/stores/games.ts` +- Test: `server/nft_api_test.go` + +**Step 1: Write the failing API tests** + +Add tests for: + +- `myNFTCards` returns linked-user owned cards only +- `searchCustomCards(query: ...)` returns NFT-backed custom cards +- unlinked users get empty results, not other users’ cards + +**Step 2: Run tests to verify they fail** + +Run: + +```bash +cd /root/.openclaw/workspace/vedh +/usr/local/go/bin/go test ./server -run 'TestMyNFTCards|TestSearchCustomCards' -v +``` + +Expected: FAIL because queries do not exist. + +**Step 3: Implement GraphQL read layer** + +Add schema: + +```graphql +extend type Query { + myNFTCards: [NFTCard!]! + searchCustomCards(query: String!): [NFTCard!]! +} +``` + +Implement resolvers in `server/nft_api.go`: + +- filter by authenticated user ownership snapshots +- join template + collection data +- optionally search name and token ID text + +Add matching client queries in `app/src/graphql/queries.ts`. + +**Step 4: Run backend tests** + +Run: + +```bash +cd /root/.openclaw/workspace/vedh +/usr/local/go/bin/go test ./server -run 'TestMyNFTCards|TestSearchCustomCards' -v +``` + +Expected: PASS. + +**Step 5: Commit** + +```bash +git add server/schema.graphql server/nft_api.go server/nft_api_test.go app/src/graphql/queries.ts app/src/stores/games.ts +git commit -m "feat: expose NFT card queries" +``` + +--- + +### Task 6: Let the board and deck flows carry NFT provenance safely + +**Files:** +- Modify: `server/schema.graphql` +- Modify: `server/games.go` +- Modify: `server/searchall_integration_test.go` +- Modify: `app/src/components/games/FormCreateGame.vue` +- Modify: `app/src/views/BoardView.vue` +- Modify: `app/src/stores/games.ts` +- Test: `app/__tests__/FormCreateGame.integration.spec.ts` +- Test: `app/e2e/create-and-join-game.spec.ts` + +**Step 1: Write the failing frontend and backend tests** + +Add tests asserting: + +- a selected NFT-backed card can be included in deck import payload +- board view renders NFT card art via `ImageURL`/metadata fallback when Scryfall art is absent +- joining and loading a game preserves `chain_id`, `contract_address`, and `token_id` + +**Step 2: Run tests to verify they fail** + +Run: + +```bash +cd /root/.openclaw/workspace/vedh/app +npm test -- FormCreateGame.integration.spec.ts +``` + +and + +```bash +cd /root/.openclaw/workspace/vedh +/usr/local/go/bin/go test ./server -run 'TestGamePersistsNFTCardMetadata' -v +``` + +Expected: FAIL because card provenance fields are missing. + +**Step 3: Add minimal provenance fields to `Card`** + +Extend `Card` / `InputCard` in `server/schema.graphql` with: + +```graphql +ChainID: String +ContractAddress: String +TokenID: String +MetadataURL: String +ImageURL: String +CardSource: String +``` + +Persist those values through game create/update/join flows in `server/games.go`. + +Update `BoardView.vue` image resolution logic to: + +1. prefer `ImageURL` +2. fall back to current Scryfall/lookup image +3. show placeholder if both absent + +**Step 4: Run tests** + +Run: + +```bash +cd /root/.openclaw/workspace/vedh +make test-api +cd /root/.openclaw/workspace/vedh/app +npm test +``` + +Expected: PASS. + +**Step 5: Commit** + +```bash +git add server/schema.graphql server/games.go app/src/components/games/FormCreateGame.vue app/src/views/BoardView.vue app/src/stores/games.ts app/__tests__/FormCreateGame.integration.spec.ts app/e2e/create-and-join-game.spec.ts +git commit -m "feat: preserve NFT card provenance in gameplay" +``` + +--- + +### Task 7: Add a user-facing wallet + NFT library screen + +**Files:** +- Create: `app/src/views/ProfileView.vue` +- Modify: `app/src/router/index.ts` +- Modify: `app/src/stores/auth.ts` +- Create: `app/src/stores/nftCards.ts` +- Create: `app/__tests__/NFTLibraryView.spec.ts` +- Modify: `app/src/graphql/queries.ts` +- Modify: `app/src/graphql/mutations.ts` + +**Step 1: Write the failing frontend test** + +Add a Vue test that expects the profile/library screen to: + +- show linked wallets +- offer a “Link wallet” button +- offer a “Sync NFT cards” button +- list imported cards + +**Step 2: Run test to verify it fails** + +Run: + +```bash +cd /root/.openclaw/workspace/vedh/app +npm test -- NFTLibraryView.spec.ts +``` + +Expected: FAIL because the route/store/view do not exist. + +**Step 3: Build the minimal UI** + +Create `ProfileView.vue` with sections: + +- Account +- Linked wallets +- Owned NFT cards + +Add a dedicated Pinia store `app/src/stores/nftCards.ts` for: + +- `linkedWallets` +- `cards` +- `syncing` +- `errorMessage` + +Add route: + +- `/profile` + +**Step 4: Run the frontend test** + +Run: + +```bash +cd /root/.openclaw/workspace/vedh/app +npm test -- NFTLibraryView.spec.ts +npm run type-check +``` + +Expected: PASS. + +**Step 5: Commit** + +```bash +git add app/src/views/ProfileView.vue app/src/router/index.ts app/src/stores/nftCards.ts app/src/stores/auth.ts app/src/graphql/queries.ts app/src/graphql/mutations.ts app/__tests__/NFTLibraryView.spec.ts +git commit -m "feat: add wallet and NFT library UI" +``` + +--- + +### Task 8: Add ownership-drift handling and game-log provenance + +**Files:** +- Modify: `server/games.go` +- Modify: `server/schema.graphql` +- Modify: `server/graphql.go` or relevant logging helpers +- Test: `server/games_test.go` +- Test: `server/graphql_httpserver_test.go` + +**Step 1: Write the failing tests** + +Add tests for: + +- logging provenance when an NFT-backed card enters a game +- marking ownership drift after a resync if the card is no longer owned +- not removing the card from an already-running game automatically + +**Step 2: Run tests to verify they fail** + +Run: + +```bash +cd /root/.openclaw/workspace/vedh +/usr/local/go/bin/go test ./server -run 'TestNFTProvenanceLogging|TestOwnershipDrift' -v +``` + +Expected: FAIL because game-log integration does not exist. + +**Step 3: Implement minimal logging + drift status** + +Add fields to NFT GraphQL read type if needed: + +```graphql +OwnershipStatus: String! +LastVerifiedAt: Time! +``` + +Add game-log payload entries when: + +- NFT card added to a board/deck +- ownership drift detected on sync + +Recommended statuses: + +- `VERIFIED` +- `STALE` +- `NOT_OWNED_ANYMORE` + +**Step 4: Run backend tests** + +Run: + +```bash +cd /root/.openclaw/workspace/vedh +/usr/local/go/bin/go test ./server -run 'TestNFTProvenanceLogging|TestOwnershipDrift' -v +``` + +Expected: PASS. + +**Step 5: Commit** + +```bash +git add server/games.go server/schema.graphql server/graphql_httpserver_test.go server/games_test.go +git commit -m "feat: log NFT provenance and ownership drift" +``` + +--- + +### Task 9: Add smoke coverage and operator docs + +**Files:** +- Modify: `README.md` +- Create: `docs/plans/2026-07-16-vedh-nft-rollout-checklist.md` +- Modify: `app/e2e/create-and-join-game.spec.ts` +- Modify: `tools/smoke/src/main.rs` (only if extending the Rust smoke path is worth it) +- Test: existing Go/Vue/Playwright suites + +**Step 1: Write the failing doc/test TODO checks** + +Create one failing smoke or E2E assertion that: + +- links a wallet in mocked mode +- syncs one NFT-backed card +- creates a game with that card visible in a board zone + +**Step 2: Run test to verify it fails** + +Run: + +```bash +cd /root/.openclaw/workspace/vedh/app +npm run test:e2e -- --grep "NFT" +``` + +Expected: FAIL because end-to-end NFT flow is not wired. + +**Step 3: Add rollout docs and smoke notes** + +Update `README.md` with: + +- required env vars for EVM RPC +- how wallet-link auth works +- how collection whitelisting works +- how to resync NFT cards + +Create rollout checklist doc with: + +- RPC provider setup +- collection whitelist seed +- smoke steps +- ownership-drift verification step +- rollback plan + +**Step 4: Run final proof commands** + +Run: + +```bash +cd /root/.openclaw/workspace/vedh +make test-api +cd /root/.openclaw/workspace/vedh/app +npm test +npm run test:e2e +``` + +Expected: PASS. + +**Step 5: Commit** + +```bash +git add README.md docs/plans/2026-07-16-vedh-nft-rollout-checklist.md app/e2e/create-and-join-game.spec.ts tools/smoke/src/main.rs +git commit -m "docs: add NFT rollout and smoke coverage" +``` + +--- + +## Suggested env vars for v1 + +Backend: + +```bash +EVM_RPC_URL="https://..." +NFT_SUPPORTED_CHAIN_IDS="1,8453" +NFT_COLLECTION_ALLOWLIST="1:0xabc...,8453:0xdef..." +NFT_CHALLENGE_TTL_SECONDS="300" +``` + +Frontend: + +```bash +VITE_ENABLE_NFT_CARDS="true" +``` + +## Data-shape recommendation for custom cards + +Normalize NFT metadata into a vEDH payload like: + +```json +{ + "name": "Custom Shock Drake", + "types": "Creature — Drake", + "text": "Flying\nWhen this enters, deal 2 damage to any target.", + "power": "2", + "toughness": "1", + "image_url": "https://...", + "colors": "U,R", + "source": "NFT" +} +``` + +That keeps game rendering simple because the board already expects card-ish fields. + +## Biggest risks + +1. **Wallet auth complexity** + - Mitigation: keep existing auth, only add linking +2. **Untrusted NFT metadata quality** + - Mitigation: whitelist collections and normalize metadata server-side +3. **Board UI assumptions around Scryfall/MTG cards** + - Mitigation: add explicit `ImageURL` + `CardSource` before trying to reuse search flows everywhere +4. **Scope explosion into marketplace work** + - Mitigation: explicitly reject mint/list/sell in v1 +5. **Ownership drift during a match** + - Mitigation: treat ownership as import-time verification plus resync status, not hard real-time gameplay enforcement + +## Recommended first implementation slice + +If you want the fastest path to something real, do only these tasks first: + +1. Task 1 — schema/migrations +2. Task 2 — wallet linking +3. Task 3 — EVM provider +4. Task 4 — sync owned NFT cards +5. Task 7 — basic wallet/library UI + +That gives you a usable ownership-tracked custom-card library before touching the board UX too deeply. + +## Recommendation + +My recommendation is **EVM read-only, whitelisted collections, linked-wallet auth, no minting** for v1. + +That gets vEDH into NFTs in a way that actually matches the product: tracking, identity, provenance, and play usage — not speculative marketplace baggage.