diff --git a/README.md b/README.md index 09cb918..e391ccf 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,53 @@ Typical flow: - Set the `PATCHES_BUCKET` environment variable to the name of your patches bucket (e.g., `patches`) - Set the `NEXT_PUBLIC_HACK_COVERS_DOMAIN` environment variable to the public URL of your covers bucket (e.g., `http://localhost:9000/covers`) +### Seed data + +After Supabase and MinIO are running, reset the database and upload dev fixtures: + +``` +npm run db:reset +``` + +This runs `supabase db reset` (migrations only — `[db.seed]` is disabled for CI), applies [`supabase/seed.sql`](supabase/seed.sql) via the local Supabase Postgres container, then uploads the shared BPS and xdelta patches plus cover images to MinIO via [`scripts/seed-storage.mjs`](scripts/seed-storage.mjs). + +You can also run the steps separately: + +``` +supabase db reset +docker exec -i supabase_db_pokemon-romhack-platform psql -U postgres -d postgres -v ON_ERROR_STOP=1 < supabase/seed.sql +npm run seed:storage +``` + +**Dev login accounts** (password `Password1` for all): + +| Email | Username | Role | +|-------|----------|------| +| `admin@hackdex.local` | `admin` | Admin (`claims_admin`) | +| `creator@hackdex.local` | `creator` | Regular creator | +| `creator2@hackdex.local` | `creator2` | Owns pending hacks | + +**Seeded hacks (highlights):** + +| Slug | Status | Purpose | +|------|--------|---------| +| `seed-emerald-demo` | Approved | Baseline discover, download, patch (`poke_emerald`); box art, social links; edit tags to see "New" badges | +| `seed-pending-demo` | Pending | Missing patch/screenshot warnings; unassigned | +| `seed-pending-ready` | Pending | Ready-for-review (patch + covers); assigned to admin; verification contact on the unverified owner | +| `seed-patcher-only-{account}-{1\|2\|3}` | Approved | Nine editable sandboxes (3 per account), patcher-only (`None` permission) | +| `seed-all-downloads` | Approved | Direct download on all published versions (`All`); custom patcher over 1.0 + 2.0 | +| `seed-current-only` | Approved | Direct download on current version only (`Current`); `Demo` completion status | +| `seed-draft-version` | Approved | Unpublished draft + publish UI | +| `seed-archived-version` | Approved | Archived older version + restore | +| `seed-third-party` | Approved | Third-party author display | +| `seed-xdelta-demo` | Approved | Published xdelta patch (`format = xdelta`) | + +BPS patch rows share `seed-shared.bps`. The xdelta row on `seed-xdelta-demo` uses `seed-shared.xdelta`. Complete hacks have ≥3 cover images (`{slug}/cover-1.png` …) fetched from placehold.co during `seed:storage`. + +To test in-browser patching on `seed-emerald-demo`, link a local **Pokémon Emerald** ROM (CRC32 `1f1c08fb`). The BPS fixture is [`public/patches/example_patch.bps`](public/patches/example_patch.bps), uploaded as `seed-shared.bps`. The xdelta fixture is [`public/patches/example_patch.xdelta`](public/patches/example_patch.xdelta), uploaded as `seed-shared.xdelta`. + +**Note:** `seed:storage` requires `S3_*`, `PATCHES_BUCKET`, and `COVERS_BUCKET` in `.env.local` or `.env.development.local`, plus `NEXT_PUBLIC_HACK_COVERS_DOMAIN` for viewing covers in the app. It needs network access to placehold.co for cover images. If `PATCH_TOKEN_SECRET` and `PATCHES_DOWNLOAD_BASE_URL` are set, patch downloads route through the Cloudflare Worker instead of MinIO — unset those for local MinIO testing. + ### Install & run ``` diff --git a/package.json b/package.json index 312c1a5..ecab22d 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,8 @@ "build": "next build --turbopack", "start": "next start", "db:pull": "supabase db pull && supabase migration up && supabase gen types --local > src/types/db.ts", + "db:reset": "supabase db reset && docker exec -i supabase_db_pokemon-romhack-platform psql -U postgres -d postgres -v ON_ERROR_STOP=1 < supabase/seed.sql && npm run seed:storage", + "seed:storage": "node --env-file=.env.local --env-file=.env.development.local scripts/seed-storage.mjs", "discord:register": "node scripts/register-discord-commands.mjs", "postinstall": "patch-package" }, diff --git a/public/patches/example_patch.xdelta b/public/patches/example_patch.xdelta new file mode 100644 index 0000000..827aeba Binary files /dev/null and b/public/patches/example_patch.xdelta differ diff --git a/scripts/seed-storage.mjs b/scripts/seed-storage.mjs new file mode 100644 index 0000000..d948d5d --- /dev/null +++ b/scripts/seed-storage.mjs @@ -0,0 +1,173 @@ +/** + * Upload dev seed fixtures to MinIO. + * + * Shared constants (keep in sync with supabase/seed.sql): + * SEED_SHARED_BPS = seed-shared.bps + * SEED_SHARED_XDELTA = seed-shared.xdelta + * SEED_APPROVED_SLUG = seed-emerald-demo + * SEED_PENDING_SLUG = seed-pending-demo + * + * Usage: + * npm run seed:storage + * + * Requires MinIO/S3 env vars (S3_ENDPOINT, S3_PORT, etc.) and PATCHES_BUCKET. + * Cover seeding also requires COVERS_BUCKET and network access to placehold.co. + */ + +import { existsSync } from "node:fs"; +import { stat } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { Client } from "minio"; + +export const SEED_SHARED_BPS = "seed-shared.bps"; +export const SEED_SHARED_XDELTA = "seed-shared.xdelta"; +const PATCHES_BUCKET = process.env.PATCHES_BUCKET || "patches"; +const COVERS_BUCKET = process.env.COVERS_BUCKET || "covers"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const BPS_FIXTURE_PATH = path.resolve(__dirname, "../public/patches/example_patch.bps"); +const XDELTA_FIXTURE_PATH = path.resolve(__dirname, "../public/patches/example_patch.xdelta"); + +/** Hacks that get ≥3 cover images (all complete seeded hacks). */ +const COMPLETE_HACK_SLUGS = [ + "seed-emerald-demo", + "seed-pending-ready", + "seed-patcher-only-admin-1", + "seed-patcher-only-admin-2", + "seed-patcher-only-admin-3", + "seed-patcher-only-creator-1", + "seed-patcher-only-creator-2", + "seed-patcher-only-creator-3", + "seed-patcher-only-creator2-1", + "seed-patcher-only-creator2-2", + "seed-patcher-only-creator2-3", + "seed-all-downloads", + "seed-current-only", + "seed-draft-version", + "seed-archived-version", + "seed-third-party", + "seed-xdelta-demo", +]; + +const COVER_PALETTES = [ + { bg: "1a1a2e", text: "ffffff" }, + { bg: "16213e", text: "e94560" }, + { bg: "0f3460", text: "ffffff" }, + { bg: "2d6a4f", text: "ffffff" }, + { bg: "40916c", text: "d8f3dc" }, + { bg: "5c4d7d", text: "ffffff" }, +]; + +function requireEnv(name) { + const value = process.env[name]; + if (!value) { + console.error(`Missing required environment variable: ${name}`); + console.error("Load env via npm run seed:storage (uses --env-file flags) or export vars manually."); + process.exit(1); + } + return value; +} + +async function ensureBucket(client, bucket) { + const exists = await client.bucketExists(bucket); + if (!exists) { + console.log(`Creating bucket "${bucket}"...`); + await client.makeBucket(bucket, ""); + } +} + +function coverObjectKey(slug, index) { + return `${slug}/cover-${index}.png`; +} + +function placeholdUrl(slug, index) { + const palette = COVER_PALETTES[(slug.length + index) % COVER_PALETTES.length]; + const label = slug.replace(/^seed-/, "").replace(/-/g, " "); + const text = encodeURIComponent(`Cover ${index}\n${label}`); + return `https://placehold.co/240x160/${palette.bg}/${palette.text}/png?text=${text}`; +} + +async function uploadPatchFixture(client, fixturePath, objectKey, expectedHint) { + if (!existsSync(fixturePath)) { + console.error(`Fixture not found: ${fixturePath}`); + console.error(expectedHint); + process.exit(1); + } + + const { size } = await stat(fixturePath); + console.log(`Uploading ${fixturePath} (${(size / 1024 / 1024).toFixed(1)} MB)`); + console.log(` → ${PATCHES_BUCKET}/${objectKey}`); + + await client.fPutObject(PATCHES_BUCKET, objectKey, fixturePath, { + "Content-Type": "application/octet-stream", + }); +} + +async function uploadSharedPatches(client) { + await uploadPatchFixture( + client, + BPS_FIXTURE_PATH, + SEED_SHARED_BPS, + "Expected public/patches/example_patch.bps (Pokémon Emerald dev patch).", + ); + await uploadPatchFixture( + client, + XDELTA_FIXTURE_PATH, + SEED_SHARED_XDELTA, + "Expected public/patches/example_patch.xdelta (Pokémon Emerald xdelta fixture).", + ); +} + +async function uploadCovers(client) { + let uploaded = 0; + + for (const slug of COMPLETE_HACK_SLUGS) { + for (let i = 1; i <= 3; i++) { + const objectKey = coverObjectKey(slug, i); + const url = placeholdUrl(slug, i); + const response = await fetch(url); + if (!response.ok) { + throw new Error(`placehold.co fetch failed for ${slug} cover ${i}: ${response.status}`); + } + const buffer = Buffer.from(await response.arrayBuffer()); + await client.putObject(COVERS_BUCKET, objectKey, buffer, buffer.length, { + "Content-Type": "image/png", + }); + uploaded++; + } + } + + console.log(`Uploaded ${uploaded} cover images to ${COVERS_BUCKET}/`); +} + +async function main() { + const endPoint = requireEnv("S3_ENDPOINT"); + const port = parseInt(requireEnv("S3_PORT"), 10); + const accessKey = requireEnv("S3_ACCESS_KEY_ID"); + const secretKey = requireEnv("S3_SECRET_ACCESS_KEY"); + const useSSL = process.env.S3_USE_SSL === "true"; + + const client = new Client({ endPoint, port, accessKey, secretKey, useSSL }); + + await ensureBucket(client, PATCHES_BUCKET); + await ensureBucket(client, COVERS_BUCKET); + + await uploadSharedPatches(client); + await uploadCovers(client); + + console.log("Done."); + console.log(""); + console.log("To test in-browser patching:"); + console.log(" 1. Open /hack/seed-emerald-demo"); + console.log(" 2. Link a local Pokémon Emerald ROM (CRC32: 1f1c08fb)"); + console.log(" 3. Patch and download"); +} + +main().catch((err) => { + console.error("seed:storage failed:", err.message || err); + if (err.code === "ECONNREFUSED" || err.code === "ENOTFOUND") { + console.error("Is MinIO running and reachable at S3_ENDPOINT:S3_PORT?"); + } + process.exit(1); +}); diff --git a/supabase/seed.sql b/supabase/seed.sql new file mode 100644 index 0000000..3fe39d8 --- /dev/null +++ b/supabase/seed.sql @@ -0,0 +1,546 @@ +-- Development seed data for local Supabase. +-- Run via: supabase db reset (or supabase db query --local -f supabase/seed.sql) +-- +-- Shared constants (keep in sync with scripts/seed-storage.mjs): +-- SEED_SHARED_BPS = seed-shared.bps +-- SEED_SHARED_XDELTA = seed-shared.xdelta +-- SEED_APPROVED_SLUG = seed-emerald-demo +-- SEED_PENDING_SLUG = seed-pending-demo + +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +-- Fixed user UUIDs +-- admin: 11111111-1111-1111-1111-111111111111 +-- creator: 22222222-2222-2222-2222-222222222222 +-- creator2: 33333333-3333-3333-3333-333333333333 + +DO $$ +DECLARE + v_password text := crypt('Password1', gen_salt('bf')); +BEGIN + -- Admin + INSERT INTO auth.users ( + id, instance_id, aud, role, email, encrypted_password, + email_confirmed_at, + confirmation_token, recovery_token, email_change_token_new, email_change, + email_change_token_current, reauthentication_token, phone_change_token, + raw_app_meta_data, raw_user_meta_data, created_at, updated_at + ) VALUES ( + '11111111-1111-1111-1111-111111111111', + '00000000-0000-0000-0000-000000000000', + 'authenticated', 'authenticated', + 'admin@hackdex.local', v_password, now(), + '', '', '', '', + '', '', '', + '{"provider":"email","providers":["email"],"claims_admin":true}'::jsonb, + '{"full_name":"Admin User"}'::jsonb, + now(), now() + ); + + INSERT INTO auth.identities ( + id, user_id, identity_data, provider, provider_id, + last_sign_in_at, created_at, updated_at + ) VALUES ( + '11111111-1111-1111-1111-111111111111', + '11111111-1111-1111-1111-111111111111', + '{"sub":"11111111-1111-1111-1111-111111111111","email":"admin@hackdex.local","email_verified":true}'::jsonb, + 'email', '11111111-1111-1111-1111-111111111111', + now(), now(), now() + ); + + -- Creator (owns approved hack) + INSERT INTO auth.users ( + id, instance_id, aud, role, email, encrypted_password, + email_confirmed_at, + confirmation_token, recovery_token, email_change_token_new, email_change, + email_change_token_current, reauthentication_token, phone_change_token, + raw_app_meta_data, raw_user_meta_data, created_at, updated_at + ) VALUES ( + '22222222-2222-2222-2222-222222222222', + '00000000-0000-0000-0000-000000000000', + 'authenticated', 'authenticated', + 'creator@hackdex.local', v_password, now(), + '', '', '', '', + '', '', '', + '{"provider":"email","providers":["email"]}'::jsonb, + '{"full_name":"Creator User"}'::jsonb, + now(), now() + ); + + INSERT INTO auth.identities ( + id, user_id, identity_data, provider, provider_id, + last_sign_in_at, created_at, updated_at + ) VALUES ( + '22222222-2222-2222-2222-222222222222', + '22222222-2222-2222-2222-222222222222', + '{"sub":"22222222-2222-2222-2222-222222222222","email":"creator@hackdex.local","email_verified":true}'::jsonb, + 'email', '22222222-2222-2222-2222-222222222222', + now(), now(), now() + ); + + -- Creator 2 (owns pending hack) + INSERT INTO auth.users ( + id, instance_id, aud, role, email, encrypted_password, + email_confirmed_at, + confirmation_token, recovery_token, email_change_token_new, email_change, + email_change_token_current, reauthentication_token, phone_change_token, + raw_app_meta_data, raw_user_meta_data, created_at, updated_at + ) VALUES ( + '33333333-3333-3333-3333-333333333333', + '00000000-0000-0000-0000-000000000000', + 'authenticated', 'authenticated', + 'creator2@hackdex.local', v_password, now(), + '', '', '', '', + '', '', '', + '{"provider":"email","providers":["email"]}'::jsonb, + '{"full_name":"Creator Two"}'::jsonb, + now(), now() + ); + + INSERT INTO auth.identities ( + id, user_id, identity_data, provider, provider_id, + last_sign_in_at, created_at, updated_at + ) VALUES ( + '33333333-3333-3333-3333-333333333333', + '33333333-3333-3333-3333-333333333333', + '{"sub":"33333333-3333-3333-3333-333333333333","email":"creator2@hackdex.local","email_verified":true}'::jsonb, + 'email', '33333333-3333-3333-3333-333333333333', + now(), now(), now() + ); +END $$; + +-- Profiles (handle_new_user trigger creates rows; set usernames for dashboard access) +UPDATE public.profiles SET username = 'admin', full_name = 'Admin User', verified = true WHERE id = '11111111-1111-1111-1111-111111111111'; +UPDATE public.profiles SET username = 'creator', full_name = 'Creator User', verified = true WHERE id = '22222222-2222-2222-2222-222222222222'; +UPDATE public.profiles SET username = 'creator2', full_name = 'Creator Two', verified = false WHERE id = '33333333-3333-3333-3333-333333333333'; + +-- Tag catalog (103 tags from prod-like export; tags 93/94/96 use now() for New-tag demo on seed-emerald-demo) +INSERT INTO public.tags (id, name, category, created_at) VALUES + (1, 'Additional Story', 'Altered', null), + (2, 'Day/Night Cycle', 'Altered', null), + (3, 'Double Battles', 'Altered', null), + (4, 'Fairy Type', 'Altered', null), + (5, 'Gigantamax', 'Altered', null), + (6, 'Megas', 'Altered', null), + (7, 'Move Changes', 'Altered', null), + (8, 'PSS', 'Altered', null), + (9, 'Stat Changes', 'Altered', null), + (10, 'Tera', 'Altered', null), + (11, 'Type Changes', 'Altered', null), + (12, 'Type Chart Changes', 'Altered', null), + (13, 'Z-Moves', 'Altered', null), + (14, 'Difficulty Options', 'Difficulty', null), + (15, 'Easy', 'Difficulty', null), + (16, 'Hard', 'Difficulty', null), + (17, 'Kaizo Difficulty', 'Difficulty', null), + (18, 'Level Caps (Mandatory)', 'Difficulty', null), + (19, 'Level Caps (Optional)', 'Difficulty', null), + (20, 'Nuzlocke Mode', 'Difficulty', null), + (21, 'Rare Candies', 'Difficulty', null), + (22, 'Vanilla+ Difficulty', 'Difficulty', null), + (23, 'Vanilla++ Difficulty', 'Difficulty', null), + (24, 'Demo', 'Gameplay', null), + (25, 'Level Scaling', 'Difficulty', null), + (26, 'Multiplayer Compatibility', 'Gameplay', null), + (27, 'Open World', 'Gameplay', null), + (28, 'Post-Game Content', 'Gameplay', null), + (29, 'Quests', 'Gameplay', null), + (30, 'Roguelite', 'Category', null), + (31, 'Story', 'Category', null), + (32, 'Vanilla Multiplayer Compatibility', 'Gameplay', null), + (33, 'Custom Abilities', 'New', null), + (34, 'Custom Battle Gimmick', 'New', null), + (35, 'Custom Moves', 'New', null), + (36, 'Custom Types', 'New', null), + (37, 'New Items', 'New', null), + (38, 'New Mechanics', 'New', null), + (39, 'New Music', 'New', null), + (40, 'New Region', 'New', null), + (41, 'Betamon', 'Pokédex', null), + (42, 'Custom Dex', 'Pokédex', null), + (43, 'Custom Megas', 'Pokédex', null), + (44, 'Custom Regionals', 'Pokédex', null), + (45, 'Fakemon', 'Pokédex', null), + (46, 'Fusions', 'Pokédex', null), + (47, 'Gen 1 NatDex', 'Pokédex', null), + (48, 'Gen 2 NatDex', 'Pokédex', null), + (49, 'Gen 3 NatDex', 'Pokédex', null), + (50, 'Gen 4 NatDex', 'Pokédex', null), + (51, 'Gen 5 NatDex', 'Pokédex', null), + (52, 'Gen 6 NatDex', 'Pokédex', null), + (53, 'Gen 7 NatDex', 'Pokédex', null), + (54, 'Gen 8 NatDex', 'Pokédex', null), + (55, 'Gen 9 NatDex', 'Pokédex', null), + (56, 'New Forms', 'Pokédex', null), + (57, 'Some Fakemon', 'Pokédex', null), + (58, 'Vanilla Dex', 'Pokédex', null), + (59, 'Bug Fixes', 'Quality of Life', null), + (60, 'Built-in Randomizer', 'Quality of Life', null), + (61, 'Clarified Natures', 'Quality of Life', null), + (62, 'Decapitalization', 'Quality of Life', null), + (63, 'DexNav', 'Quality of Life', null), + (64, 'Easy EVs/IVs/DVs', 'Quality of Life', null), + (65, 'Easy EXP', 'Quality of Life', null), + (66, 'Following Pokémon', 'Quality of Life', null), + (67, 'HM Improvements', 'Quality of Life', null), + (68, 'Reusable TMs', 'Quality of Life', null), + (69, 'Revised Trade Evos', 'Quality of Life', null), + (70, 'Running Shoes', 'Quality of Life', null), + (71, 'Tons of QoL', 'Quality of Life', null), + (72, 'Visible IVs/EVs/DVs', 'Quality of Life', null), + (73, 'Wonder Trade', 'Quality of Life', null), + (74, '<1 hour', 'Scale', null), + (75, '1-2 hrs', 'Scale', null), + (76, '2-4 hrs', 'Scale', null), + (77, '4-8 hrs', 'Scale', null), + (78, '8+ hrs', 'Scale', null), + (79, 'New Graphics', 'Graphics', null), + (80, 'Vanilla Graphics', 'Graphics', null), + (81, 'Comedy', 'Tone', null), + (82, 'Exploration', 'Tone', null), + (83, 'Lighthearted', 'Tone', null), + (84, 'Mature', 'Tone', null), + (85, 'Non-Traditional', 'Category', null), + (86, 'Puzzle', 'Category', null), + (87, 'Traditional', 'Category', null), + (88, 'CFRU', null, null), + (89, 'hg-engine', null, null), + (90, 'pokeemerald-expansion', null, null), + (91, 'Vanilla', 'Gameplay', '2026-01-01 07:00:00+00'), + (92, 'Translation', 'Altered', '2026-01-01 07:00:00+00'), + (93, 'Demake', 'Category', now()), + (94, 'Minigame', 'Category', now()), + (95, 'Escape Room', 'Category', '2026-01-01 07:00:00+00'), + (96, 'Technical Concept', 'Category', now()), + (97, '<100 Dex Size', 'Pokédex', '2026-01-01 07:00:00+00'), + (98, '100-300 Dex Size', 'Pokédex', '2026-01-01 07:00:00+00'), + (99, '300-500 Dex Size', 'Pokédex', '2026-01-01 07:00:00+00'), + (100, '500-700 Dex Size', 'Pokédex', '2026-01-01 07:00:00+00'), + (101, '700+ Dex Size', 'Pokédex', null), + (102, 'Built-in Cheats', 'Quality of Life', null), + (103, 'Profanity', 'Tone', null); + +SELECT setval(pg_get_serial_sequence('public.tags', 'id'), (SELECT MAX(id) FROM public.tags)); + +-- Hacks (18 total; tags_updated_at past on emerald + pending-demo for New-tag demo) +-- downloads defaults to 0; patch_downloads inserts increment via trigger +INSERT INTO public.hacks ( + slug, title, summary, description, base_rom, patch_url, version, + created_by, language, approved, approved_at, approved_by, + completion_status, patches_download_permission, + is_archive, published, tags_updated_at, + original_author, permission_from +) VALUES + ( + 'seed-emerald-demo', + 'Seed Emerald Demo', + 'Approved dev hack for discover, download, and in-browser patching.', + 'Seeded development data using the shared example BPS for Pokémon Emerald. Run npm run seed:storage after db reset.', + 'poke_emerald', '', '1.0', + '22222222-2222-2222-2222-222222222222', + 'English', true, now(), '11111111-1111-1111-1111-111111111111', + 'Complete', 'Current', + false, false, now() - interval '30 days', + null, null + ), + ( + 'seed-pending-demo', + 'Seed Pending Demo', + 'Pending dev hack for testing the admin approval dashboard.', + 'Intentionally unapproved. Log in as admin@hackdex.local to review on the dashboard.', + 'poke_emerald', '', '0.1', + '33333333-3333-3333-3333-333333333333', + 'English', false, null, null, + 'Beta', 'None', + false, false, now() - interval '30 days', + null, null + ), + ( + 'seed-pending-ready', + 'Seed Pending Ready', + 'Pending hack with patch, covers, and tags — ready for admin review.', + 'Has patch and gallery metadata so approve UI shows no missing-screenshot or missing-patch warnings.', + 'poke_emerald', '', '0.2', + '33333333-3333-3333-3333-333333333333', + 'English', false, null, null, + 'Beta', 'None', + false, false, default, + null, null + ), + ( + 'seed-all-downloads', + 'Seed All Downloads', + 'Approved hack with All download permission across published versions.', + 'Tests direct BPS download on every published version row.', + 'poke_emerald', '', '2.0', + '22222222-2222-2222-2222-222222222222', + 'English', true, now(), '11111111-1111-1111-1111-111111111111', + 'Alpha', 'All', + false, false, default, + null, null + ), + ( + 'seed-current-only', + 'Seed Current Only', + 'Approved hack with Current download permission.', + 'Direct download only on the current published patch version.', + 'poke_emerald', '', '2.0', + '22222222-2222-2222-2222-222222222222', + 'English', true, now(), '11111111-1111-1111-1111-111111111111', + 'Demo', 'Current', + false, false, default, + null, null + ), + ( + 'seed-draft-version', + 'Seed Draft Version', + 'Approved hack with a published current version and an unpublished draft.', + 'Tests draft/publish UI in the version editor.', + 'poke_emerald', '', '2.0', + '22222222-2222-2222-2222-222222222222', + 'English', true, now(), '11111111-1111-1111-1111-111111111111', + 'Complete', 'Current', + false, false, default, + null, null + ), + ( + 'seed-archived-version', + 'Seed Archived Version', + 'Approved hack with an archived older version and a current release.', + 'Tests show-archived and restore flows on the versions page.', + 'poke_emerald', '', '2.0', + '22222222-2222-2222-2222-222222222222', + 'English', true, now(), '11111111-1111-1111-1111-111111111111', + 'Complete', 'Current', + false, false, default, + null, null + ), + ( + 'seed-third-party', + 'Seed Third Party', + 'Approved hack credited to an external original author.', + 'Tests third-party author and permission display on the hack page.', + 'poke_emerald', '', '1.0', + '22222222-2222-2222-2222-222222222222', + 'English', true, now(), '11111111-1111-1111-1111-111111111111', + 'Complete', 'Current', + false, false, default, + 'Famous Hacker', 'Famous Hacker' + ), + ( + 'seed-xdelta-demo', + 'Seed Xdelta Demo', + 'Approved hack with a published xdelta patch.', + 'Seeded development data using the shared example xdelta for Pokémon Emerald. Run npm run seed:storage after db reset.', + 'poke_emerald', '', '1.0', + '22222222-2222-2222-2222-222222222222', + 'English', true, now(), '11111111-1111-1111-1111-111111111111', + 'Complete', 'Current', + false, false, default, + null, null + ); + +UPDATE public.hacks SET + assigned_admin = '11111111-1111-1111-1111-111111111111', + verification_contact_info = 'creator2@hackdex.local / Discord: creator2' +WHERE slug = 'seed-pending-ready'; + +UPDATE public.hacks SET + box_art = 'https://images.launchbox-app.com/1fde111d-8805-47ba-af07-03b436e4dfde.jpg', + social_links = '{ + "discord": "https://discord.gg/example", + "twitter": "https://twitter.com/example", + "pokecommunity": "https://www.pokecommunity.com/threads/example.1/", + "github": "https://github.com/example/seed-emerald-demo" + }'::jsonb +WHERE slug = 'seed-emerald-demo'; + +UPDATE public.hacks SET + custom_version_name = '1.0 + 2.0' +WHERE slug = 'seed-all-downloads'; + +-- Nine patcher-only sandboxes (3 per owner account) +DO $$ +DECLARE + v_admin uuid := '11111111-1111-1111-1111-111111111111'; + v_creator uuid := '22222222-2222-2222-2222-222222222222'; + v_creator2 uuid := '33333333-3333-3333-3333-333333333333'; + v_owner record; + v_slug text; + v_num int; + v_idx int := 0; + v_tag_set_idx int; + v_tags int[]; + v_tag_id int; + v_pos int; + v_cover int; +BEGIN + FOR v_owner IN + SELECT * FROM (VALUES + ('admin', v_admin), + ('creator', v_creator), + ('creator2', v_creator2) + ) AS owners(label, uid) + LOOP + FOR v_num IN 1..3 LOOP + v_idx := v_idx + 1; + v_slug := 'seed-patcher-only-' || v_owner.label || '-' || v_num; + v_tag_set_idx := (v_idx - 1) % 3; + IF v_tag_set_idx = 0 THEN + v_tags := ARRAY[27, 71, 49]; + ELSIF v_tag_set_idx = 1 THEN + v_tags := ARRAY[16, 71, 90]; + ELSE + v_tags := ARRAY[31, 71, 49]; + END IF; + + INSERT INTO public.hacks ( + slug, title, summary, description, base_rom, patch_url, version, + created_by, language, approved, approved_at, approved_by, + completion_status, patches_download_permission, downloads, + is_archive, published, tags_updated_at + ) VALUES ( + v_slug, + 'Seed Patcher Only ' || initcap(v_owner.label) || ' ' || v_num, + 'Editable sandbox with patcher-only download permission (None).', + 'Approved sandbox for testing in-browser patcher with no direct BPS download.', + 'poke_emerald', '', '2.0', + v_owner.uid, + 'English', true, now(), v_admin, + 'Complete', 'None', 0, + false, false, default + ); + + INSERT INTO public.patches (parent_hack, version, filename, bucket, published, published_at, changelog) + VALUES + (v_slug, '1.0', 'seed-shared.bps', 'patches', true, now(), 'Initial sandbox version.'), + (v_slug, '1.1', 'seed-shared.bps', 'patches', true, now(), 'Minor sandbox update.'), + (v_slug, '2.0', 'seed-shared.bps', 'patches', true, now(), 'Current sandbox version.'); + + UPDATE public.hacks + SET current_patch = ( + SELECT id FROM public.patches + WHERE parent_hack = v_slug AND version = '2.0' + LIMIT 1 + ) + WHERE slug = v_slug; + + v_pos := 0; + FOREACH v_tag_id IN ARRAY v_tags LOOP + v_pos := v_pos + 1; + INSERT INTO public.hack_tags (hack_slug, tag_id, "order") + VALUES (v_slug, v_tag_id, v_pos); + END LOOP; + + FOR v_cover IN 1..3 LOOP + INSERT INTO public.hack_covers (hack_slug, url, position) + VALUES (v_slug, v_slug || '/cover-' || v_cover || '.png', v_cover); + END LOOP; + END LOOP; + END LOOP; +END $$; + +-- Patches for non-sandbox hacks (BPS rows omit format; default is bps) +INSERT INTO public.patches (parent_hack, version, filename, bucket, published, published_at, changelog) VALUES + ('seed-emerald-demo', '1.0', 'seed-shared.bps', 'patches', true, now(), 'Initial seeded version for local development.'), + ('seed-pending-ready', '0.2', 'seed-shared.bps', 'patches', true, now(), 'Ready-for-review pending version.'), + ('seed-all-downloads', '1.0', 'seed-shared.bps', 'patches', true, now(), 'First published version.'), + ('seed-all-downloads', '2.0', 'seed-shared.bps', 'patches', true, now(), 'Current published version.'), + ('seed-current-only', '1.0', 'seed-shared.bps', 'patches', true, now(), 'First published version.'), + ('seed-current-only', '2.0', 'seed-shared.bps', 'patches', true, now(), 'Current published version.'), + ('seed-draft-version', '1.0', 'seed-shared.bps', 'patches', true, now(), 'First published version.'), + ('seed-draft-version', '2.0', 'seed-shared.bps', 'patches', true, now(), 'Current published version.'), + ('seed-draft-version', '2.1', 'seed-shared.bps', 'patches', false, null, 'Unpublished draft version.'), + ('seed-archived-version', '1.0', 'seed-shared.bps', 'patches', true, now(), 'Archived older version.'), + ('seed-archived-version', '2.0', 'seed-shared.bps', 'patches', true, now(), 'Current published version.'), + ('seed-third-party', '1.0', 'seed-shared.bps', 'patches', true, now(), 'Third-party credited version.'); + +INSERT INTO public.patches (parent_hack, version, filename, bucket, published, published_at, changelog, format) VALUES + ('seed-xdelta-demo', '1.0', 'seed-shared.xdelta', 'patches', true, now(), 'Xdelta seeded version.', 'xdelta'); + +UPDATE public.patches +SET archived = true, archived_at = now() +WHERE parent_hack = 'seed-archived-version' AND version = '1.0'; + +UPDATE public.hacks SET current_patch = (SELECT id FROM public.patches WHERE parent_hack = 'seed-emerald-demo' AND version = '1.0' LIMIT 1) WHERE slug = 'seed-emerald-demo'; +UPDATE public.hacks SET current_patch = (SELECT id FROM public.patches WHERE parent_hack = 'seed-pending-ready' AND version = '0.2' LIMIT 1) WHERE slug = 'seed-pending-ready'; +UPDATE public.hacks SET current_patch = (SELECT id FROM public.patches WHERE parent_hack = 'seed-all-downloads' AND version = '2.0' LIMIT 1) WHERE slug = 'seed-all-downloads'; +UPDATE public.hacks SET current_patch = (SELECT id FROM public.patches WHERE parent_hack = 'seed-current-only' AND version = '2.0' LIMIT 1) WHERE slug = 'seed-current-only'; +UPDATE public.hacks SET current_patch = (SELECT id FROM public.patches WHERE parent_hack = 'seed-draft-version' AND version = '2.0' LIMIT 1) WHERE slug = 'seed-draft-version'; +UPDATE public.hacks SET current_patch = (SELECT id FROM public.patches WHERE parent_hack = 'seed-archived-version' AND version = '2.0' LIMIT 1) WHERE slug = 'seed-archived-version'; +UPDATE public.hacks SET current_patch = (SELECT id FROM public.patches WHERE parent_hack = 'seed-third-party' AND version = '1.0' LIMIT 1) WHERE slug = 'seed-third-party'; +UPDATE public.hacks SET current_patch = (SELECT id FROM public.patches WHERE parent_hack = 'seed-xdelta-demo' AND version = '1.0' LIMIT 1) WHERE slug = 'seed-xdelta-demo'; + +-- hack_tags (all complete hacks except seed-pending-demo) +INSERT INTO public.hack_tags (hack_slug, tag_id, "order") VALUES + ('seed-emerald-demo', 31, 1), + ('seed-emerald-demo', 71, 2), + ('seed-emerald-demo', 90, 3), + ('seed-emerald-demo', 49, 4), + ('seed-pending-ready', 31, 1), + ('seed-pending-ready', 59, 2), + ('seed-pending-ready', 71, 3), + ('seed-all-downloads', 26, 1), + ('seed-all-downloads', 71, 2), + ('seed-all-downloads', 79, 3), + ('seed-current-only', 27, 1), + ('seed-current-only', 49, 2), + ('seed-current-only', 71, 3), + ('seed-draft-version', 31, 1), + ('seed-draft-version', 71, 2), + ('seed-draft-version', 90, 3), + ('seed-archived-version', 28, 1), + ('seed-archived-version', 49, 2), + ('seed-archived-version', 71, 3), + ('seed-third-party', 45, 1), + ('seed-third-party', 71, 2), + ('seed-third-party', 31, 3), + ('seed-xdelta-demo', 38, 1), + ('seed-xdelta-demo', 71, 2), + ('seed-xdelta-demo', 90, 3); + +-- hack_covers (17 complete hacks × 3 covers; seed-pending-demo has none) +DO $$ +DECLARE + v_slug text; +BEGIN + FOREACH v_slug IN ARRAY ARRAY[ + 'seed-emerald-demo', + 'seed-pending-ready', + 'seed-all-downloads', + 'seed-current-only', + 'seed-draft-version', + 'seed-archived-version', + 'seed-third-party', + 'seed-xdelta-demo' + ] LOOP + INSERT INTO public.hack_covers (hack_slug, url, position) VALUES + (v_slug, v_slug || '/cover-1.png', 1), + (v_slug, v_slug || '/cover-2.png', 2), + (v_slug, v_slug || '/cover-3.png', 3); + END LOOP; +END $$; + +-- Custom patcher versions for seed-all-downloads (published 1.0 then 2.0) +INSERT INTO public.hack_patcher_patches (hack_slug, patch_id, sort_order) +SELECT 'seed-all-downloads', p.id, spec.sort_order +FROM (VALUES ('1.0', 1), ('2.0', 2)) AS spec(version, sort_order) +JOIN public.patches p ON p.parent_hack = 'seed-all-downloads' AND p.version = spec.version; + +-- patch_downloads: dated unique (patch, device_id) rows across ~30 days. +-- Trigger increments hacks.downloads; do not also hardcode downloads on hacks. +INSERT INTO public.patch_downloads (patch, device_id, created_at) +SELECT + p.id, + 'seed-device-' || spec.slug || '-' || spec.ver || '-' || gs.n, + now() - (gs.n * 30.0 / spec.cnt) * interval '1 day' +FROM ( + VALUES + ('seed-emerald-demo', '1.0', 30), + ('seed-all-downloads', '1.0', 9), + ('seed-all-downloads', '2.0', 18), + ('seed-current-only', '2.0', 12), + ('seed-third-party', '1.0', 15) +) AS spec(slug, ver, cnt) +JOIN public.patches p ON p.parent_hack = spec.slug AND p.version = spec.ver +CROSS JOIN LATERAL generate_series(0, spec.cnt - 1) AS gs(n);