Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,6 @@ snfoundry_cache/
.env
accounts/
.snfoundry_cache/
coverage/
coverage/
node_modules/
dist/
3 changes: 3 additions & 0 deletions app/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules/
dist/
*.png
114 changes: 114 additions & 0 deletions app/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# Add a Beast

Self-service web app for adding a species to the bestiary. Upload pixel art,
name it, choose where it can be minted — one transaction, no permission needed.

```bash
pnpm install
pnpm build:sdk # the app consumes the SDK's build output
pnpm dev:app
```

## Configuration

Defaults point at the Sepolia deployment in `docs/sepolia-v3-deployment.md`.
Override with a `.env`:

```
VITE_STARKNET_RPC_URL=https://api.cartridge.gg/x/starknet/sepolia
VITE_BEASTS_NFT=0x...
VITE_BEASTS_REGISTRY=0x...
```

## Pages

Hash routing, so every page is a real shareable URL on a static host.

| URL | |
|---|---|
| `#/` | Add a Beast |
| `#/collection/0xabc…` | Every Beast an address holds. **Swap the address to view any wallet** — no connection needed |
| `#/beasts` | The bestiary, one card per species. The original 75 are behind a toggle |
| `#/beasts/76` | Every Beast of one species, strongest first |
| `#/manage` | Species you control |
| `#/manage/76` | Per-species controls |

Grouping the bestiary by species is the default because the collection is
unbounded and a single species can hold 1,243 Beasts — a flat list of every
token would be unreadable long before it was useful.

Species pages read the contract's own per-species rank list rather than
scanning, so the ordering shown is the one the collection itself uses.

## What the app does

**Register.** Four art variants are required — which one a Beast shows is
decided by the shiny and animated bits in its token ID, so a species cannot ship
a partial set. Art is validated client-side against the same rules the contract
enforces (media type, base64 structure, PNG/GIF magic bytes) *and* decoded by
the browser, because a file that will not decode here renders broken for every
holder. Registration mints the artist's Genesis Beast in the same transaction.

**Manage.** Lists every species the connected wallet holds the artist role for,
with its live art, and opens the per-species controls: replace art, rotate or
pause the minter, lock either one permanently, opt into kill stats, transfer
the artist role, or graduate to a custom art provider.

The list comes from the wallet's own tokens: the collection exposes
`token_of_owner_by_index`, and a Genesis Beast is any token with no affixes, so
the species a wallet controls fall straight out of a local decode. That works
because the **artist role is ownership of the Genesis Beast** — there is no
separate role to drift from the token. Everything is artist-only on-chain; the
UI hides what the caller cannot do and the contract is the real gate.

Transferring a species means sending its Genesis Beast, which the dashboard
spells out before it asks for a signature.

Art thumbnails come from each species' own provider, which for a custom
provider is arbitrary contract code. One that reverts costs its own thumbnail,
not the whole list.

## Art loading

Art is fetched per Beast, not per species. A community provider receives the
whole decoded Beast, so it may legitimately vary art by affix, tier or level —
caching by species would show the wrong picture for exactly the providers that
make the interface worth having. Requests are batched and the cache is keyed by
token ID, shared across pages.

The contract validates art structurally — media type, base64, magic bytes — but
cannot prove a payload decodes. A tile whose art will not render says so rather
than showing a broken-image icon.

## The preview card is an approximation

`CardPreview` mirrors the layout of the on-chain SVG so an artist can judge
their work in context before paying for a transaction. It is **not** the
contract's renderer — that lives in `src/beast_svg.cairo` and only produces
output once a species exists. Porting it byte-for-byte to TypeScript would let
the preview be exact; until then, treat the card as indicative and `token_uri`
as the truth.

## Session policy

The Cartridge connector pre-approves the registry's *management* entrypoints, so
an artist signs once and can then iterate without a popup per change.
`register_beast_with_art` is deliberately excluded: it mints a provenance token
and permanently assigns a species ID, so it should always be an explicit
signature.

## Smoke test

`scripts/smoke.mjs` drives the built app in headless Chromium against the live
deployment — it checks the chain read renders, name validation rejects an
injection attempt, the preview derives power correctly, and the dashboard loads
a real species.

```bash
pnpm build && pnpm preview &
node scripts/smoke.mjs
```

It needs a Playwright browser; in this dev container that means
`LD_LIBRARY_PATH=/workspace/.playwright-libs/root/usr/lib/x86_64-linux-gnu` and
an `executablePath` pointing at the cached Chromium.
12 changes: 12 additions & 0 deletions app/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Add a Beast — onchain bestiary</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
30 changes: 30 additions & 0 deletions app/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"name": "@provable-games/beasts-app",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@cartridge/connector": "^0.13.12",
"@cartridge/controller": "^0.13.12",
"@provable-games/beasts-sdk": "workspace:*",
"@starknet-react/chains": "5.0.3",
"@starknet-react/core": "^5.0.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"starknet": "9.4.2"
},
"devDependencies": {
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.3.0",
"playwright": "^1.62.1",
"typescript": "^5.8.3",
"vite": "^5.4.0"
}
}
83 changes: 83 additions & 0 deletions app/scripts/smoke.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { chromium } from 'playwright';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

// 32x32 PNG: magenta border on all four sides, green corner-to-corner
// diagonal. Real Beast art is this size, and the border is the tell — if the
// preview crops, an edge goes missing.
const TEST_PNG =
'iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAIAAAD8GO2jAAAAiklEQVR4nLXNSw6AIAyEYQ7B2vvfEk1sULFAHzOZf9FVv9JKoyar9WAk76+LZDwAyfgADGME4IYCYA0dABpTAGWsAIixAfLGHkgaJiBjWIGw4QBihg8IGG7Aa0QAlxEE7EYcMBopwGJkga0BANYGBlgYMGBmIAHVAAN/Aw8MBgV4GyygG0TgNmTUTqHjAu38soSbAAAAAElFTkSuQmCC';

const fixtureDir = mkdtempSync(join(tmpdir(), 'beasts-smoke-'));
const pngPath = join(fixtureDir, 'test.png');
writeFileSync(pngPath, Buffer.from(TEST_PNG, 'base64'));

const browser = await chromium.launch({
executablePath:
'/home/ubuntu/.cache/ms-playwright/chromium_headless_shell-1228/chrome-headless-shell-linux64/chrome-headless-shell',
});
Comment on lines +16 to +19
const page = await browser.newPage({ viewport: { width: 1280, height: 1400 } });
const errors = [];
page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
page.on('pageerror', (e) => errors.push(`pageerror: ${e.message}`));

await page.goto('http://localhost:4173/', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(5000);

console.log('H1:', await page.textContent('h1'));
const body = await page.textContent('body');
const m = body.match(/(\d+) species in the bestiary so far/);
console.log('LIVE CHAIN READ:', m ? m[0] : 'FAILED — no species count rendered');
console.log('art slots:', await page.locator('.art-slot').count());
console.log('preview card:', await page.locator('.card').count());

// Client-side name validation, no wallet needed.
await page.fill('input[placeholder="Gloomfang"]', 'bad"name');
await page.waitForTimeout(200);
console.log('rejects injection name:', JSON.stringify(await page.locator('.field__error').first().textContent()));

await page.fill('input[placeholder="Gloomfang"]', 'Gloomfang');
await page.waitForTimeout(200);
console.log('accepts valid name — card title:', JSON.stringify(await page.textContent('.card__title')));

// Tier drives the power number on the preview card.
await page.selectOption('select >> nth=1', '1');
await page.waitForTimeout(200);
const power = await page.locator('.stat', { hasText: 'Power' }).textContent();
console.log('tier 1 power (level 10 x 5):', JSON.stringify(power));

// Preview sizing. A percentage height against an `aspect-ratio` parent
// resolves to `auto`, so a square Beast used to size itself from its own 1:1
// ratio, overflow the wider frame, and get cropped by `overflow: hidden`.
await page.locator('.art-slot__drop input[type=file]').first().setInputFiles(pngPath);
await page.waitForTimeout(1200);
const artBox = await page.locator('.card__art').boundingBox();
const imgBox = await page.locator('.card__art img').boundingBox();
console.log('preview art fits its frame:',
imgBox.height <= artBox.height + 1 && imgBox.width <= artBox.width + 1,
`(img ${Math.round(imgBox.width)}x${Math.round(imgBox.height)} in frame ${Math.round(artBox.width)}x${Math.round(artBox.height)})`);

await page.screenshot({ path: 'smoke-register.png', fullPage: true });

// Manage lists the species the connected wallet controls, so with no wallet
// it has to ask for one rather than opening an empty page.
await page.click('button:has-text("Manage")');
await page.waitForTimeout(800);
console.log('Manage without a wallet prompts connect:',
(await page.locator('.modal').count()) > 0 && (await page.locator('.mine').count()) === 0);

// Wallet picker: both wallet families must be offered, and a wallet whose
// extension is absent must say so rather than fail silently.
const wallets = await page.locator('.wallet__name').allTextContents();
const states = await page.locator('.wallet__state').allTextContents();
console.log('wallets offered:', wallets.map((w, i) => `${w}${states[i] ? ` (${states[i]})` : ''}`));
// A centred fixed overlay: narrow, and vertically centred in the viewport
// whatever the page scroll position.
const box = await page.locator('.modal').boundingBox();
const vh = page.viewportSize().height;
const centred = Math.abs(box.y + box.height / 2 - vh / 2) < 40;
console.log('modal is a centred overlay:', box.width <= 400 && centred);

console.log('CONSOLE ERRORS:', errors.length ? errors.slice(0, 4) : 'none');
await browser.close();
Loading
Loading