commit a61f3a9c333941449b22b3dd2702fad46848c2b1 Author: droideparanoico Date: Thu Jun 11 17:28:46 2026 +0200 Initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..651a34e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,15 @@ +# Prevent local build artifacts from leaking into Docker +node_modules/ +frontend/node_modules/ +dist/ +frontend/dist/ + +# Runtime data +data/ + +# IDE +.vscode/ +.idea/ + +# Binary +dostalgia diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..914d2b5 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# DOStalgia — Environment Configuration +# Copy this file to .env and fill in your values: +# cp .env.example .env + +# Twitch Client ID for IGDB metadata auto-scrape. +# Get yours at https://dev.twitch.tv/console/apps → Create Application +# (Set OAuth Redirect URL to http://localhost, Category to "Other") +# Optional — IGDB features gracefully skip if not set. +# TWITCH_CLIENT_ID=your_client_id_here +# TWITCH_CLIENT_SECRET=your_client_secret_here diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..2d375f0 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,45 @@ +name: Publish Docker image + +on: + release: + types: [published] + +jobs: + push: + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_TOKEN }} + + - name: Extract tags + id: meta + uses: docker/metadata-action@v5 + with: + images: droideparanoico/dostalgia + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=raw,value=latest,enable={{is_default_branch}} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..22cb277 --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# Dependencies +node_modules/ +frontend/node_modules/ + +# Environment — secrets +.env +.env.local + +# Built output +frontend/dist/ +dist/ + +# Maven / Java +target/ +*.class +*.jar +*.war + +# Runtime data +data/games/* +data/artwork/* +data/saves/* +!data/games/.gitkeep +!data/artwork/.gitkeep +!data/saves/.gitkeep + +# IDE +.vscode/ +.idea/ +*.iml +*.ipr +*.iws + +# Test/upload artifacts +*.zip +*.ZIP +*.jsdos +*.JSDOS + +# OS +.DS_Store +Thumbs.db diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..347d6cd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +# DOStalgia — Quarkus multi-stage build + +# ─── 1. Build frontend ─────────────────────────── +FROM node:20-alpine AS frontend +WORKDIR /build +COPY frontend/package.json frontend/package-lock.json ./ +RUN npm ci 2>/dev/null || npm install --legacy-peer-deps +COPY frontend/ ./ +RUN npm run build +RUN npm test + +# ─── 2. Build Quarkus app ──────────────────────── +FROM maven:3-eclipse-temurin-21-alpine AS build +WORKDIR /build +COPY pom.xml ./ +COPY src ./src +# Copy the built frontend into the source tree so maven-resources-plugin picks it up +COPY --from=frontend /build/dist ./frontend/dist/ +RUN mvn package -q -DskipFrontend=true -DskipFrontendTests=true + +# ─── 3. Runtime ────────────────────────────────── +FROM eclipse-temurin:21-jre-alpine +WORKDIR /app +# Quarkus uber-jar layout +COPY --from=build /build/target/quarkus-app/ /app/ +EXPOSE 8765 +VOLUME /data +ENV DOSTALGIA_DATA_DIR=/data +ENTRYPOINT ["java", "-XX:MinHeapFreeRatio=10", "-XX:MaxHeapFreeRatio=20", "-jar", "quarkus-run.jar"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b835321 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 droideparanoico + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..6d4c38f --- /dev/null +++ b/README.md @@ -0,0 +1,134 @@ +# DOStalgia 🕹️ + +A nostalgic DOS game hub. Upload your old DOS games, auto-scrape artwork and metadata from IGDB, and play them directly in your browser via js-dos (DOSBox compiled to WebAssembly). + +### Library view +![library](images/library.jpeg "Library") + +### Game detail view +![game](images/game.jpeg "Game") + +## Features + +### 🎮 Play in the browser +Every uploaded game is packaged into a `.jsdos` bundle — a standard ZIP with embedded DOSBox configuration. When you hit **Play**, js-dos v8 is loaded from CDN and starts the emulator instantly in your browser. No plugins, no native installs. + +**Save states are automatic** — js-dos persists your game progress to the browser's storage. Come back anytime and pick up where you left off. + +### 📦 Handles any file structure +DOS games come in all shapes. DOStalgia handles them transparently: + +- **Flattening** — If your ZIP has a single root directory (e.g. `doom/` with all files inside), the extractor flattens it so the game files sit at the bundle root. No extra nesting. +- **Subdirectory games** — If files are deeper, the autoexec automatically `cd`s to the right directory before launching the executable. +- **CD images** — Games shipped on CD-ROM often need the disc mounted. DOStalgia detects `.iso`, `.cue`, `.img`, `.ccd`, and `.bin` files, fixes broken CloneCD `.cue` references (where the referenced `.bin` is actually `.img`), and mounts them in both `dosbox.conf` and `jsdos.json`. +- **CD-only games** — If the archive contains only CD images with no executable, a CD-only bundle is created that mounts the disc and drops you at the DOS prompt. +- **Hardcoded paths** — `ConfigPatcher` scans game config files for hardcoded absolute paths (e.g. `C:\FALLOUT1\MASTER.DAT`) that broke after flattening, and rewrites them to relative paths. + +Other solutions such as [RomM](https://www.romm.app/) generate raw ZIP bundles and require you to write your own `dosbox.conf` with manual `mount` and `imgmount` commands. DOStalgia automates all of this for you. + +### 🔍 Smart executable detection +On upload, DOStalgia scans every `.exe`, `.com`, and `.bat` file and picks the best candidate as the main executable using a scoring system: + +1. **Not an installer** — INSTALL/SETUP/CONFIG executables are deprioritised +2. **DOS executables** — Pure DOS apps are preferred over Windows ones +3. **Larger files** — Bigger executables are more likely to be the game +4. **Shallow depth** — Files closer to the root are preferred +5. **Self-extractor filtering** — PKZIP/PKSFX stubs are filtered out + +All discovered executables are stored and available in the **Edit** page, where you can pick a different one via radio buttons. + +Again, RomM requires you to manually specify the executable in a custom `dosbox.conf`, while DOStalgia detects and configures it for you. + +> **⚠️ Cache note:** If you change the executable, the `.jsdos` bundle is patched in-place. Your browser may serve a cached version of the old bundle — if the game doesn't launch with the new executable, **hard-refresh** the play page (Ctrl+Shift+R / Cmd+Shift+R). + +### 🛠 One-click Setup launcher +Many DOS games include a `SETUP.EXE`, `INSTALL.EXE`, or `CONFIG.EXE` used to configure sound, controls, and graphics. When DOStalgia detects one: + +- A **🛠 Setup** button appears on the game detail page +- Clicking it launches the setup executable *without modifying the main game bundle* +- The setup bundle is generated **on-the-fly** by the server — a modified `.jsdos` is streamed with the setup executable in the autoexec, then discarded. Nothing is written to disk. + +This is not supported in RomM, where you would have to run the setup manually from the DOS prompt every time. + +### 🪟 Windows game detection +DOSBox can't run Windows executables. DOStalgia's `PlatformDetector` reads MZ/PE/NE headers to detect Windows executables: + +- The game detail page shows a **🪟 Requires Windows 3.1** badge +- The **Play** button is disabled with "Unplayable" text +- If you click Play anyway, a warning overlay explains the limitation with a **Try anyway** fallback +- Setup executables that are Windows-native are also filtered out from the Setup button + +### 📡 IGDB metadata & media +DOStalgia integrates with [IGDB](https://www.igdb.com/) (via Twitch OAuth2) to auto-populate game info: + +- **Auto Scrape** — On upload, DOStalgia searches IGDB by title and fills in year, genre, developer, publisher, description, and cover art. If IGDB finds a DOS result, it prioritises it. +- **Manual Search** — From the game detail page you can search IGDB by any query, browse results with cover thumbnails and DOS badges, and apply the one you want. +- **Media** — Videos (YouTube embeds) and screenshots (1080p) are fetched and displayed in a scrollable media gallery with a preview player. + +**Setup:** + +1. Go to https://dev.twitch.tv/console/apps → **Register Your Application** +2. Name: `dostalgia` (or anything), OAuth Redirect URL: `http://localhost`, Category: **Other** +3. Copy the **Client ID** and generate a **Client Secret** +4. Provide them via environment variables: + +Method | How +--- | --- +Docker run | `-e TWITCH_CLIENT_ID=xxx -e TWITCH_CLIENT_SECRET=yyy` +Docker Compose | Copy `.env.example` to `.env` and fill in the values +Local dev | `export TWITCH_CLIENT_ID=xxx TWITCH_CLIENT_SECRET=yyy` + +IGDB features degrade gracefully — if credentials aren't set, uploads and metadata editing still work, just without auto-scrape. + +## Quick Start + +### With Docker Compose (easiest) + +```bash +# 1. (Optional) Enable IGDB metadata auto-scrape +cp .env.example .env +# Edit .env with your Twitch credentials (see IGDB section below) + +# 2. Pull & run +docker compose up -d +``` + +Open http://localhost:8765 + +### With Docker + +```bash +docker run -p 8765:8765 -v $(pwd)/data:/data \ + -e TWITCH_CLIENT_ID=your_id \ + -e TWITCH_CLIENT_SECRET=your_secret \ + droideparanoico/dostalgia +``` + +## Development (without Docker) + +Terminal 1 — Frontend dev server: +```bash +cd frontend && npm install && npm run dev +``` + +Terminal 2 — Quarkus dev server: +```bash +mvn quarkus:dev +``` + +Open http://localhost:5173 (Vite proxies API calls to Quarkus on port 8765) + +### Production build (local) +```bash +cd frontend && npm install && npm run build +mvn package -DskipTests +java -jar target/quarkus-app/quarkus-run.jar +``` + +## Architecture + +- **Backend**: Quarkus (Java 21, JAX-RS) — REST endpoints, zero external database +- **Frontend**: Svelte 5 SPA with hash-based routing +- **Emulation**: js-dos v8 loaded from CDN, runs DOSBox in WebAssembly +- **Storage**: JSON-per-game under `/data/games/{id}/game.json` +- **Saves**: Browser localStorage / indexedDB (managed by js-dos) \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fc6c0b5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +services: + dostalgia: + image: droideparanoico/dostalgia + container_name: dostalgia + restart: unless-stopped + ports: + - "8765:8765" + volumes: + # Configure here your data directory if you want persistence. + - ./data:/data + env_file: + - .env + environment: + - DOSTALGIA_DATA_DIR=/data \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..1116143 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,16 @@ + + + + + + + DOStalgia + + + + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..66c0173 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3087 @@ +{ + "name": "dostalgia-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dostalgia-frontend", + "version": "0.1.0", + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^4.0.0", + "@testing-library/svelte": "^5.3.1", + "jsdom": "^29.1.1", + "svelte": "^5.0.0", + "vite": "^5.0.0", + "vitest": "^4.1.8" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.1.tgz", + "integrity": "sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz", + "integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz", + "integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==", + "dev": true, + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-4.0.4.tgz", + "integrity": "sha512-0ba1RQ/PHen5FGpdSrW7Y3fAMQjrXantECALeOiOdBdzR5+5vPP6HVZRLmZaQL+W8m++o+haIAKq5qT+MiZ7VA==", + "dev": true, + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^3.0.0-next.0||^3.0.0", + "debug": "^4.3.7", + "deepmerge": "^4.3.1", + "kleur": "^4.1.5", + "magic-string": "^0.30.12", + "vitefu": "^1.0.3" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "svelte": "^5.0.0-next.96 || ^5.0.0", + "vite": "^5.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-3.0.1.tgz", + "integrity": "sha512-2CKypmj1sM4GE7HjllT7UKmo4Q6L5xFRd7VMGEWhYnZ+wc6AUVU01IBd7yUi6WnFndEwWoMNOd6e8UjoN0nbvQ==", + "dev": true, + "dependencies": { + "debug": "^4.3.7" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^4.0.0-next.0||^4.0.0", + "svelte": "^5.0.0-next.96 || ^5.0.0", + "vite": "^5.0.0" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/svelte": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-5.3.1.tgz", + "integrity": "sha512-8Ez7ZOqW5geRf9PF5rkuopODe5RGy3I9XR+kc7zHh26gBiktLaxTfKmhlGaSHYUOTQE7wFsLMN9xCJVCszw47w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@testing-library/dom": "9.x.x || 10.x.x", + "@testing-library/svelte-core": "1.0.0" + }, + "engines": { + "node": ">= 10" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0", + "vite": "*", + "vitest": "*" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/svelte-core": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@testing-library/svelte-core/-/svelte-core-1.0.0.tgz", + "integrity": "sha512-VkUePoLV6oOYwSUvX6ShA8KLnJqZiYMIbP2JW2t0GLWLkJxKGvuH5qrrZBV/X7cXFnLGuFQEC7RheYiZOW68KQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true + }, + "node_modules/@vitest/expect": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", + "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", + "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", + "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.8", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", + "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "@vitest/utils": "4.1.8", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", + "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", + "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.8", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", + "dev": true + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true + }, + "node_modules/esrap": { + "version": "2.2.9", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.9.tgz", + "integrity": "sha512-4KijP+NxCWthMCUC3qHbE6n4vCjqgJS1uAYKhuT/GWfFTf1Qyive2TgOjep+gzbSzRfnNyaN/UU9YmdOt8Eg0A==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true + }, + "node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.2.tgz", + "integrity": "sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup/node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/svelte": { + "version": "5.55.9", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.9.tgz", + "integrity": "sha512-fTjjT8cHLDwigcu2j3pv7Jq04LklXevPB8uBgyHNiTXv+RMNvVnrjS4UEYrLMkhuq1vpCodHjiW+z/95SDs/fg==", + "dev": true, + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.9", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.2.tgz", + "integrity": "sha512-kCwffuaH8ntKtygnWe1b4BJKWiCUH30n5KfoTr6IchcXOwR7chAOFJxFrH3vjANafUYrIA4a7SDL+nn7SiR4Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.2" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.2.tgz", + "integrity": "sha512-nwEyF4vl4RSJjwSjBUmOSxc3BFPoIFdlRthJ6e+5v9P3bHNsoD06UjuqMUspqp7vsEZ1beaHi1km+optiE17yA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/undici": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.2.tgz", + "integrity": "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", + "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.8", + "@vitest/mocker": "4.1.8", + "@vitest/pretty-format": "4.1.8", + "@vitest/runner": "4.1.8", + "@vitest/snapshot": "4.1.8", + "@vitest/spy": "4.1.8", + "@vitest/utils": "4.1.8", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.8", + "@vitest/browser-preview": "4.1.8", + "@vitest/browser-webdriverio": "4.1.8", + "@vitest/coverage-istanbul": "4.1.8", + "@vitest/coverage-v8": "4.1.8", + "@vitest/ui": "4.1.8", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.8", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", + "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.8", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..68e545b --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,21 @@ +{ + "name": "dostalgia-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^4.0.0", + "@testing-library/svelte": "^5.3.1", + "jsdom": "^29.1.1", + "svelte": "^5.0.0", + "vite": "^5.0.0", + "vitest": "^4.1.8" + } +} diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte new file mode 100644 index 0000000..9cef609 --- /dev/null +++ b/frontend/src/App.svelte @@ -0,0 +1,71 @@ + + +
uploadTriggered++} /> + +
+ {#if route === "/"} + + {:else if route === "/game" && gameId} + + {:else if route === "/play" && gameId} + + {:else} +
+

Page not found

+ Back to Library +
+ {/if} +
+ + diff --git a/frontend/src/app.css b/frontend/src/app.css new file mode 100644 index 0000000..0cf7e73 --- /dev/null +++ b/frontend/src/app.css @@ -0,0 +1,250 @@ +/* ═══════════════════════════════════════════════════════════════ + DOSTALGIA — Phosphor Dark Theme + Inspired by classic green-screen CRT monitors (IBM 5151) + ═══════════════════════════════════════════════════════════════ */ + +:root { + /* Phosphor greens — from brightest glow to darkest trace */ + --phosphor: #33ff33; + --phosphor-glow: #66ff66; + --phosphor-dim: #22aa22; + --phosphor-dark: #116611; + --phosphor-burn: #0a330a; + + /* Backgrounds */ + --bg: #0a0a0a; + --bg-elevated: #111111; + --surface: #1a1a1a; + --surface-hover: #222222; + --surface-active: #2a2a2a; + + /* Borders */ + --border: #2a2a2a; + --border-glow: #33ff3340; + + /* Text */ + --text: #cccccc; + --text-bright: #e8e8e8; + --text-dim: #777777; + + /* Sizing */ + --max-width: 1200px; + --header-height: 64px; + --radius: 8px; + --radius-sm: 4px; + + /* Typography */ + --font-mono: "Press Start 2P", monospace; + --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; +} + +/* ═══════════════════════════════════════════════════════════════ + RESET & BASE + ═══════════════════════════════════════════════════════════════ */ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + font-size: 16px; + -webkit-font-smoothing: antialiased; + -webkit-text-size-adjust: 100%; +} + +body { + font-family: var(--font-sans); + background: var(--bg); + color: var(--text); + min-height: 100vh; + line-height: 1.6; + overflow-x: hidden; + width: 100vw; +} + +html { + overflow-x: hidden; +} + +a { + color: var(--phosphor); + text-decoration: none; +} +a:hover { + color: var(--phosphor-glow); + text-shadow: 0 0 8px var(--phosphor-glow); +} + +img { + max-width: 100%; + display: block; +} + +/* ═══════════════════════════════════════════════════════════════ + TYPOGRAPHY + ═══════════════════════════════════════════════════════════════ */ +h1, h2, h3, h4 { + color: var(--text-bright); + font-weight: 700; + line-height: 1.3; +} +h1 { font-size: 2rem; } +h2 { font-size: 1.5rem; } +h3 { font-size: 1.2rem; } + +/* ═══════════════════════════════════════════════════════════════ + BUTTONS + ═══════════════════════════════════════════════════════════════ */ +.btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 20px; + border: 1px solid var(--phosphor-dim); + border-radius: var(--radius-sm); + background: transparent; + color: var(--phosphor); + font-family: var(--font-sans); + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + transition: all 0.15s ease; +} +.btn:hover { + background: var(--phosphor-burn); + border-color: var(--phosphor); + box-shadow: 0 0 12px var(--phosphor-dark); +} +.btn:active { + transform: scale(0.97); +} + +.btn-primary { + background: var(--phosphor-dark); + border-color: var(--phosphor); + color: var(--phosphor-glow); +} +.btn-primary:hover { + background: var(--phosphor-dim); + color: var(--bg); +} + +.btn-danger { + border-color: #cc3333; + color: #cc3333; +} +.btn-danger:hover { + background: #331111; + border-color: #ff4444; + color: #ff4444; + box-shadow: 0 0 12px #331111; +} + +/* ═══════════════════════════════════════════════════════════════ + INPUTS + ═══════════════════════════════════════════════════════════════ */ +input, +textarea, +select { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); + padding: 10px 14px; + font-family: var(--font-sans); + font-size: 0.9rem; + transition: border-color 0.15s; +} +input:focus, +textarea:focus, +select:focus { + outline: none; + border-color: var(--phosphor-dim); + box-shadow: 0 0 0 3px var(--border-glow); +} +input::placeholder { + color: var(--text-dim); +} + +/* ═══════════════════════════════════════════════════════════════ + LAYOUT UTILITIES + ═══════════════════════════════════════════════════════════════ */ +.container { + max-width: var(--max-width); + margin: 0 auto; + padding: 0 24px; +} +@media (max-width: 640px) { + .container { padding: 0 12px; } +} + +/* ═══════════════════════════════════════════════════════════════ + RESPONSIVE TOUCH ADJUSTMENTS + ═══════════════════════════════════════════════════════════════ */ +@media (max-width: 640px) { + h1 { font-size: 1.5rem; } + h2 { font-size: 1.25rem; } + .btn { padding: 8px 14px; font-size: 0.85rem; min-height: 40px; } + input, textarea, select { font-size: 16px; } /* prevents iOS zoom */ +} + +/* ═══════════════════════════════════════════════════════════════ + SCROLLBAR + ═══════════════════════════════════════════════════════════════ */ +::-webkit-scrollbar { + width: 8px; +} +::-webkit-scrollbar-track { + background: var(--bg); +} +::-webkit-scrollbar-thumb { + background: var(--surface-hover); + border-radius: 4px; +} +::-webkit-scrollbar-thumb:hover { + background: var(--phosphor-dark); +} + +/* ═══════════════════════════════════════════════════════════════ + ANIMATIONS + ═══════════════════════════════════════════════════════════════ */ +@keyframes scanline { + 0% { transform: translateY(0); } + 100% { transform: translateY(4px); } +} + +@keyframes flicker { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.97; } +} + +@keyframes phosphor-pulse { + 0%, 100% { text-shadow: 0 0 4px var(--phosphor-dim); } + 50% { text-shadow: 0 0 12px var(--phosphor), 0 0 24px var(--phosphor-dark); } +} + +/* Subtle CRT effect on hover cards */ +.crt-hover { + position: relative; +} +.crt-hover::after { + content: ""; + position: absolute; + inset: 0; + background: repeating-linear-gradient( + 0deg, + transparent, + transparent 2px, + rgba(51, 255, 51, 0.015) 2px, + rgba(51, 255, 51, 0.015) 4px + ); + pointer-events: none; + opacity: 0; + transition: opacity 0.3s; +} +.crt-hover:hover::after { + opacity: 1; +} diff --git a/frontend/src/lib/GameCard.svelte b/frontend/src/lib/GameCard.svelte new file mode 100644 index 0000000..a605a2e --- /dev/null +++ b/frontend/src/lib/GameCard.svelte @@ -0,0 +1,118 @@ + + + + + \ No newline at end of file diff --git a/frontend/src/lib/Header.svelte b/frontend/src/lib/Header.svelte new file mode 100644 index 0000000..a6840c3 --- /dev/null +++ b/frontend/src/lib/Header.svelte @@ -0,0 +1,72 @@ + + +
+ +
+ + \ No newline at end of file diff --git a/frontend/src/lib/__tests__/GameCard.test.js b/frontend/src/lib/__tests__/GameCard.test.js new file mode 100644 index 0000000..9232948 --- /dev/null +++ b/frontend/src/lib/__tests__/GameCard.test.js @@ -0,0 +1,71 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/svelte"; +import GameCard from "../GameCard.svelte"; + +function makeGame(overrides = {}) { + return { + id: "test-1", + title: "Test Game", + year: 1995, + genre: "Adventure", + platform: "dos", + has_cover: false, + ...overrides, + }; +} + +describe("GameCard", () => { + it("renders the game title", () => { + render(GameCard, { props: { game: makeGame() } }); + expect(screen.getByText("Test Game")).toBeTruthy(); + }); + + it("renders the year when provided", () => { + render(GameCard, { props: { game: makeGame({ year: 1997 }) } }); + expect(screen.getByText("1997")).toBeTruthy(); + }); + + it("does not render year when absent", () => { + render(GameCard, { props: { game: makeGame({ year: null }) } }); + expect(screen.queryByText("1995")).toBeNull(); + }); + + it("renders the genre when provided", () => { + render(GameCard, { props: { game: makeGame({ genre: "RPG" }) } }); + expect(screen.getByText("RPG")).toBeTruthy(); + }); + + it("shows a cover image when has_cover is true", () => { + const game = makeGame({ has_cover: true }); + render(GameCard, { props: { game } }); + const img = screen.getByRole("img"); + expect(img).toBeTruthy(); + expect(img.getAttribute("src")).toBe(`/games/${game.id}/cover.jpg`); + expect(img.getAttribute("alt")).toBe(game.title); + }); + + it("shows a placeholder when has_cover is false", () => { + render(GameCard, { props: { game: makeGame({ has_cover: false }) } }); + expect(screen.getByText("💾")).toBeTruthy(); + }); + + it("shows Windows badge for windows platform", () => { + render(GameCard, { + props: { game: makeGame({ platform: "windows" }) }, + }); + expect(screen.getByText(/🪟/)).toBeTruthy(); + }); + + it("does not show Windows badge for DOS platform", () => { + render(GameCard, { props: { game: makeGame({ platform: "dos" }) } }); + expect(screen.queryByText(/🪟/)).toBeNull(); + }); + + it("navigates on click", async () => { + window.location.hash = ""; + render(GameCard, { props: { game: makeGame() } }); + const btn = screen.getByRole("button"); + btn.click(); + expect(window.location.hash).toBe("#/game/test-1"); + }); +}); diff --git a/frontend/src/lib/__tests__/api.test.js b/frontend/src/lib/__tests__/api.test.js new file mode 100644 index 0000000..aea4cb6 --- /dev/null +++ b/frontend/src/lib/__tests__/api.test.js @@ -0,0 +1,68 @@ +import { describe, it, expect } from "vitest"; +import { artworkUrl, bundleUrl, setupBundleUrl } from "../api.js"; + +describe("artworkUrl", () => { + it("returns null for null input", () => { + expect(artworkUrl(null)).toBeNull(); + }); + + it("returns null for undefined input", () => { + expect(artworkUrl(undefined)).toBeNull(); + }); + + it("passes through absolute HTTP URLs", () => { + expect(artworkUrl("https://example.com/cover.jpg")).toBe( + "https://example.com/cover.jpg" + ); + }); + + it("proxies relative paths through /artwork/", () => { + expect(artworkUrl("images/game.png")).toBe("/artwork/images/game.png"); + }); + + it("preserves nested relative paths", () => { + expect(artworkUrl("some/deep/path/file.webp")).toBe( + "/artwork/some/deep/path/file.webp" + ); + }); +}); + +describe("bundleUrl", () => { + it("returns null for null game", () => { + expect(bundleUrl(null)).toBeNull(); + }); + + it("returns null for undefined game", () => { + expect(bundleUrl(undefined)).toBeNull(); + }); + + it("builds URL from bundle_file", () => { + expect(bundleUrl({ bundle_file: "mygame.zip" })).toBe("/games/mygame.zip"); + }); + + it("preserves subdirectory bundle paths", () => { + expect(bundleUrl({ bundle_file: "subdir/game.zip" })).toBe( + "/games/subdir/game.zip" + ); + }); +}); + +describe("setupBundleUrl", () => { + it("returns null for null game", () => { + expect(setupBundleUrl(null)).toBeNull(); + }); + + it("returns null for undefined game", () => { + expect(setupBundleUrl(undefined)).toBeNull(); + }); + + it("builds API setup URL from game id", () => { + expect(setupBundleUrl({ id: "game-1" })).toBe( + "/api/games/game-1/setup-bundle" + ); + }); + + it("handles games with numeric id", () => { + expect(setupBundleUrl({ id: 42 })).toBe("/api/games/42/setup-bundle"); + }); +}); diff --git a/frontend/src/lib/__tests__/router.test.js b/frontend/src/lib/__tests__/router.test.js new file mode 100644 index 0000000..bc6d190 --- /dev/null +++ b/frontend/src/lib/__tests__/router.test.js @@ -0,0 +1,33 @@ +import { describe, it, expect, vi } from "vitest"; +import { push, replace } from "../router.js"; + +describe("router", () => { + describe("push", () => { + it("sets window.location.hash with # prefix", () => { + window.location.hash = ""; + push("/game/123"); + expect(window.location.hash).toBe("#/game/123"); + }); + + it("handles root path", () => { + window.location.hash = ""; + push("/"); + expect(window.location.hash).toBe("#/"); + }); + + it("replaces hash on subsequent calls", () => { + window.location.hash = ""; + push("/first"); + push("/second"); + expect(window.location.hash).toBe("#/second"); + }); + }); + + describe("replace", () => { + it("sets hash via replace (no back-navigation allowed)", () => { + window.location.hash = "#/old"; + replace("/new"); + expect(window.location.hash).toBe("#/new"); + }); + }); +}); diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js new file mode 100644 index 0000000..34a1c41 --- /dev/null +++ b/frontend/src/lib/api.js @@ -0,0 +1,118 @@ +/** API client for DOStalgia backend. */ + +const BASE = import.meta.env.PROD ? "" : ""; // Proxy handles it in dev + +async function request(path, options = {}) { + const res = await fetch(`${BASE}${path}`, { + headers: { "Content-Type": "application/json", ...options.headers }, + ...options, + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ detail: res.statusText })); + throw new Error(err.detail || `HTTP ${res.status}`); + } + return res.json(); +} + +/** Fetch game list */ +export async function listGames(params = {}) { + const qs = new URLSearchParams(); + if (params.search) qs.set("search", params.search); + if (params.genre) qs.set("genre", params.genre); + if (params.limit) qs.set("limit", params.limit); + if (params.offset) qs.set("offset", params.offset); + return request(`/api/games?${qs}`); +} + +/** Fetch single game */ +export async function getGame(id) { + return request(`/api/games/${id}`); +} + +/** Update game metadata */ +export async function updateGame(id, data) { + return request(`/api/games/${id}`, { + method: "PATCH", + body: JSON.stringify(data), + }); +} + +/** Delete a game */ +export async function deleteGame(id) { + return request(`/api/games/${id}`, { method: "DELETE" }); +} + +/** Upload a game ZIP */ +export async function uploadGame(file, title, igdbId) { + const form = new FormData(); + form.append("file", file); + if (title) form.append("title", title); + if (igdbId) form.append("igdb_id", String(igdbId)); + + const res = await fetch(`${BASE}/api/upload`, { + method: "POST", + body: form, + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ detail: res.statusText })); + throw new Error(err.detail || `HTTP ${res.status}`); + } + return res.json(); +} + +/** Search IGDB for a game title (requires TWITCH_CLIENT_ID/SECRET set) */ +export async function searchIGDB(query) { + const res = await fetch(`${BASE}/api/igdb/search?q=${encodeURIComponent(query)}`); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })); + throw new Error(err.error || `HTTP ${res.status}`); + } + return res.json(); +} + +/** Apply IGDB metadata + cover to a game (auto or by igdb_id) */ +export async function scrapeIGDB(gameId, igdbId) { + const body = igdbId ? { igdb_id: igdbId } : {}; + const res = await fetch(`${BASE}/api/igdb/scrape/${gameId}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })); + throw new Error(err.error || `HTTP ${res.status}`); + } + return res.json(); +} + +/** Download a game's ZIP bundle */ +export function downloadGame(id) { + window.open(`${BASE}/api/games/${id}/download`, '_blank'); +} + +/** Check IGDB status */ +export async function igdbStatus() { + const res = await fetch(`${BASE}/api/igdb/status`); + return res.json(); +} + +/** Get artwork URL for a path — returns proxy URL or placeholder */ +export function artworkUrl(path) { + if (!path) return null; + if (path.startsWith("http")) return path; + return `/artwork/${path}`; +} + +/** Get game bundle URL for normal play */ +export function bundleUrl(game) { + if (!game) return null; + // Use the bundle_file path from the backend (handles both old flat layout + // and new in-directory layout transparently) + return `/games/${game.bundle_file}`; +} + +/** Get game bundle URL for setup mode (runs SETUP.EXE instead) */ +export function setupBundleUrl(game) { + if (!game) return null; + return `/api/games/${game.id}/setup-bundle`; +} diff --git a/frontend/src/lib/router.js b/frontend/src/lib/router.js new file mode 100644 index 0000000..bc63d91 --- /dev/null +++ b/frontend/src/lib/router.js @@ -0,0 +1,9 @@ +/** Simple hash-based router for SPA. */ + +export function push(path) { + window.location.hash = "#" + path; +} + +export function replace(path) { + window.location.replace("#" + path); +} diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..88ff31f --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,8 @@ +import App from "./App.svelte"; +import { mount } from "svelte"; + +const app = mount(App, { + target: document.getElementById("app"), +}); + +export default app; diff --git a/frontend/src/pages/GameDetail.svelte b/frontend/src/pages/GameDetail.svelte new file mode 100644 index 0000000..e6c6f83 --- /dev/null +++ b/frontend/src/pages/GameDetail.svelte @@ -0,0 +1,714 @@ + + +{#if loading} +
Loading...
+{:else if error && !game} +
{error}
+ ← Back to Library +{:else if game} +
+ ← Back to Library + +
+ +
+ {#if game.has_cover} + {game.title} + {:else} +
💾
+ {/if} + {#if !editing} + {#if game.year || game.genre} +
+ {#if game.year} + {game.year} + {/if} + {#if game.genre} + {game.genre} + {/if} +
+ {/if} + {#if game.developer} +

by {game.developer}{game.publisher ? ` · ${game.publisher}` : ""}

+ {/if} + {/if} +
+ + +
+ {#if editing} + +
+ + +
+
+ + +
+ +
+ +
+ {#if game.executables && game.executables.length > 0} +
+ Main executable: +
+ {#each game.executables as exe} + + {/each} +
+
+ {/if} +
+ + +
+ {:else} +

{game.title}

+ {#if game.platform} +
+ {#if game.platform === 'windows'} + 🪟 Requires Windows 3.1 — may not work in browser + {:else} + 💾 DOS + {#if game.bundle_size} + · {game.bundle_size >= 1073741824 + ? (game.bundle_size / 1073741824).toFixed(1) + ' GB' + : game.bundle_size >= 1048576 + ? Math.round(game.bundle_size / 1048576) + ' MB' + : Math.round(game.bundle_size / 1024) + ' KB'} + {/if} + {/if} +
+ {/if} + {#if game.description} +

{game.description}

+ {/if} + +
+ {#if game.ready && (game.platform !== 'windows')} + + {:else if game.ready && game.platform === 'windows'} + + {/if} + {#if game.has_setup && game.platform !== 'windows'} + + {/if} + + + +
+ + + {#if selectedMedia} +
+ 📺 Media +
+ {#if selectedMedia.type === 'video'} + + {:else if selectedMedia.type === 'screenshot'} + Screenshot + {/if} +
+
+ {#each game.videos ?? [] as video, i} + + {/each} + {#each game.screenshots ?? [] as shot} + + {/each} +
+
+ {/if} + + +
+
+ 📡 IGDB Metadata +
+ + +
+
+ + {#if igdbResults !== null} +
+ e.key === "Enter" && handleSearchIGDB()} + /> +
+ {#if igdbResults.length > 0} +
+ {#each igdbResults as result (result.igdb_id)} + + {/each} +
+ {/if} + {/if} + + {#if scrapeError} +
+ {scrapeError} +
+ {/if} +
+ {/if} +
+
+
+{/if} + + diff --git a/frontend/src/pages/Library.svelte b/frontend/src/pages/Library.svelte new file mode 100644 index 0000000..c43a1fc --- /dev/null +++ b/frontend/src/pages/Library.svelte @@ -0,0 +1,850 @@ + + +
+ + + + +
+ + + {#if hasActiveFilters()} +
+ {#each [...selectedYears] as y} + + {/each} + {#each [...selectedGenres] as g} + + {/each} +
+ {/if} + + {#if pendingFile} + + {/if} + + {#if uploadError} +
{uploadError}
+ {/if} + + {#if loading} +
Scanning library...
+ {:else if filteredGames.length === 0} +
+
💾
+

{hasActiveFilters() ? "No matching games" : "No games yet"}

+

{hasActiveFilters() ? "Try changing your filters" : "Upload a DOS game ZIP to get started"}

+ {#if hasActiveFilters()} + + {:else} + + {/if} +
+ {:else} +
+ {#each filteredGames as game (game.id)} + + {/each} +
+
{filteredGames.length} game{filteredGames.length !== 1 ? "s" : ""}
+ {/if} + + + +
+
+ + diff --git a/frontend/src/pages/Play.svelte b/frontend/src/pages/Play.svelte new file mode 100644 index 0000000..5604b66 --- /dev/null +++ b/frontend/src/pages/Play.svelte @@ -0,0 +1,300 @@ + + +
+ +
+ {#if game} + + {#if running} + + {/if} + {/if} +
+ + + {#if error && !booting} +
{error}
+ {/if} + + + {#if unsupported} +
+
🪟
+

Windows game

+

{game.title} is a Windows application and won't run in the browser DOS emulator without Windows 3.1 installed.

+
+ + +
+
+ {/if} + + + {#if !running} +
+ {#if loading} +
+

Loading game data...

+ {:else if booting} +
⚙️
+

{game?.title || ""}

+ {#if isSetup} +

Starting setup utility...

+

⚙️ Configure controls, then exit SETUP to save

+ {:else} +

Starting emulator...

+

💾 Game saves are stored automatically

+ {/if} + {:else if error} +
⚠️
+

Failed to start

+

{error}

+ {:else if !game?.ready} +
🕹️
+

Not ready

+

This game hasn't been processed yet.

+ {/if} +
+ {/if} + + +
+
+ + diff --git a/frontend/src/pages/__tests__/GameDetail.test.js b/frontend/src/pages/__tests__/GameDetail.test.js new file mode 100644 index 0000000..92547af --- /dev/null +++ b/frontend/src/pages/__tests__/GameDetail.test.js @@ -0,0 +1,185 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/svelte"; +import GameDetail from "../GameDetail.svelte"; + +// Mock api.js +vi.mock("../../lib/api.js", () => ({ + getGame: vi.fn(), + deleteGame: vi.fn(), + updateGame: vi.fn(), + searchIGDB: vi.fn(), + scrapeIGDB: vi.fn(), + downloadGame: vi.fn(), + bundleUrl: vi.fn(), + setupBundleUrl: vi.fn(), + artworkUrl: vi.fn(), +})); + +// Mock router +vi.mock("../../lib/router.js", () => ({ + push: vi.fn(), +})); + +import * as api from "../../lib/api.js"; + +function makeGame(overrides = {}) { + return { + id: "game-1", + title: "Commander Keen", + year: 1991, + genre: "Platformer", + developer: "id Software", + publisher: "Apogee", + description: "A classic DOS platformer.", + platform: "dos", + ready: true, + has_cover: false, + has_setup: false, + bundle_file: "commander-keen.zip", + bundle_size: 512000, + executables: ["keen.exe"], + executable: null, + videos: [], + screenshots: [], + ...overrides, + }; +} + +describe("GameDetail", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("shows loading state initially", () => { + api.getGame.mockReturnValue(new Promise(() => {})); + render(GameDetail, { props: { id: "game-1" } }); + expect(screen.getByText("Loading...")).toBeTruthy(); + }); + + it("shows error when game is not found", async () => { + api.getGame.mockRejectedValue(new Error("Game not found")); + render(GameDetail, { props: { id: "missing" } }); + await vi.waitFor(() => { + expect(screen.getByText("Game not found")).toBeTruthy(); + }); + }); + + it("renders game title", async () => { + api.getGame.mockResolvedValue(makeGame()); + render(GameDetail, { props: { id: "game-1" } }); + await vi.waitFor(() => { + expect(screen.getByText("Commander Keen")).toBeTruthy(); + }); + }); + + it("renders developer and publisher", async () => { + api.getGame.mockResolvedValue( + makeGame({ developer: "id Software", publisher: "Apogee" }) + ); + render(GameDetail, { props: { id: "game-1" } }); + await vi.waitFor(() => { + expect(screen.getByText(/id Software/)).toBeTruthy(); + expect(screen.getByText(/Apogee/)).toBeTruthy(); + }); + }); + + it("renders year and genre as links", async () => { + api.getGame.mockResolvedValue( + makeGame({ year: 1991, genre: "Platformer" }) + ); + render(GameDetail, { props: { id: "game-1" } }); + await vi.waitFor(() => { + expect(screen.getByText("1991").closest("a")).toBeTruthy(); + expect(screen.getByText("Platformer").closest("a")).toBeTruthy(); + }); + }); + + it("shows DOS badge for dos platform", async () => { + api.getGame.mockResolvedValue(makeGame({ platform: "dos" })); + render(GameDetail, { props: { id: "game-1" } }); + await vi.waitFor(() => { + expect(screen.getByText("💾 DOS")).toBeTruthy(); + }); + }); + + it("shows Windows badge for windows platform", async () => { + api.getGame.mockResolvedValue(makeGame({ platform: "windows" })); + render(GameDetail, { props: { id: "game-1" } }); + await vi.waitFor(() => { + expect(screen.getByText(/Windows 3\.1/)).toBeTruthy(); + }); + }); + + it("shows Play button for ready DOS games", async () => { + api.getGame.mockResolvedValue( + makeGame({ ready: true, platform: "dos" }) + ); + render(GameDetail, { props: { id: "game-1" } }); + await vi.waitFor(() => { + expect(screen.getByText("▶ Play")).toBeTruthy(); + }); + }); + + it("shows disabled Play button for Windows games", async () => { + api.getGame.mockResolvedValue( + makeGame({ ready: true, platform: "windows" }) + ); + render(GameDetail, { props: { id: "game-1" } }); + await vi.waitFor(() => { + expect(screen.getByText("▶ Unplayable")).toBeTruthy(); + }); + }); + + it("shows Setup button when has_setup is true", async () => { + api.getGame.mockResolvedValue( + makeGame({ has_setup: true }) + ); + render(GameDetail, { props: { id: "game-1" } }); + await vi.waitFor(() => { + expect(screen.getByText("🛠 Setup")).toBeTruthy(); + }); + }); + + it("shows Edit, Download, Delete buttons", async () => { + api.getGame.mockResolvedValue(makeGame()); + render(GameDetail, { props: { id: "game-1" } }); + await vi.waitFor(() => { + expect(screen.getByText("✏ Edit")).toBeTruthy(); + expect(screen.getByText("⬇ Download")).toBeTruthy(); + expect(screen.getByText("✕ Delete")).toBeTruthy(); + }); + }); + + it("enters edit mode when Edit button is clicked", async () => { + api.getGame.mockResolvedValue(makeGame()); + render(GameDetail, { props: { id: "game-1" } }); + await vi.waitFor(() => { + expect(screen.getByText("✏ Edit")).toBeTruthy(); + }); + screen.getByText("✏ Edit").click(); + await vi.waitFor(() => { + expect(screen.getByText("Save")).toBeTruthy(); + expect(screen.getByText("Cancel")).toBeTruthy(); + }); + }); + + it("shows cover placeholder when no cover", async () => { + api.getGame.mockResolvedValue(makeGame({ has_cover: false })); + render(GameDetail, { props: { id: "game-1" } }); + await vi.waitFor(() => { + expect(screen.getByText("💾")).toBeTruthy(); + }); + }); + + it("shows description when provided", async () => { + api.getGame.mockResolvedValue( + makeGame({ description: "A classic DOS platformer." }) + ); + render(GameDetail, { props: { id: "game-1" } }); + await vi.waitFor(() => { + expect( + screen.getByText("A classic DOS platformer.") + ).toBeTruthy(); + }); + }); +}); diff --git a/frontend/src/pages/__tests__/Library.test.js b/frontend/src/pages/__tests__/Library.test.js new file mode 100644 index 0000000..a8dbf8a --- /dev/null +++ b/frontend/src/pages/__tests__/Library.test.js @@ -0,0 +1,143 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/svelte"; +import Library from "../Library.svelte"; + +// Mock the API module that Library imports +vi.mock("../../lib/api.js", () => ({ + listGames: vi.fn(), + uploadGame: vi.fn(), + searchIGDB: vi.fn(), +})); + +import * as api from "../../lib/api.js"; + +function makeGame(overrides = {}) { + return { + id: "g1", + title: "Test Game", + year: 1995, + genre: "Adventure", + platform: "dos", + has_cover: false, + ...overrides, + }; +} + +describe("Library", () => { + beforeEach(() => { + vi.clearAllMocks(); + window.location.hash = "#/"; + }); + + it("shows loading state initially", () => { + api.listGames.mockReturnValue(new Promise(() => {})); // never resolves + render(Library); + expect(screen.getByText("Scanning library...")).toBeTruthy(); + }); + + it("shows empty state when no games", async () => { + api.listGames.mockResolvedValue({ games: [] }); + render(Library); + await vi.waitFor(() => { + expect(screen.getByText("No games yet")).toBeTruthy(); + }); + }); + + it("renders game cards when games load", async () => { + api.listGames.mockResolvedValue({ + games: [ + makeGame({ id: "g1", title: "Game One" }), + makeGame({ id: "g2", title: "Game Two" }), + ], + }); + render(Library); + await vi.waitFor(() => { + expect(screen.getByText("Game One")).toBeTruthy(); + expect(screen.getByText("Game Two")).toBeTruthy(); + }); + }); + + it("shows game count", async () => { + api.listGames.mockResolvedValue({ + games: [ + makeGame({ id: "g1", title: "One" }), + makeGame({ id: "g2", title: "Two" }), + ], + }); + render(Library); + await vi.waitFor(() => { + expect(screen.getByText("2 games")).toBeTruthy(); + }); + }); + + it("shows singular game count for one game", async () => { + api.listGames.mockResolvedValue({ + games: [makeGame({ id: "g1", title: "Only" })], + }); + render(Library); + await vi.waitFor(() => { + expect(screen.getByText("1 game")).toBeTruthy(); + }); + }); + + describe("filters", () => { + it("shows filter options derived from loaded games", async () => { + api.listGames.mockResolvedValue({ + games: [ + makeGame({ id: "g1", genre: "RPG", year: 1997 }), + makeGame({ id: "g2", genre: "RPG", year: 1998 }), + makeGame({ id: "g3", genre: "Adventure", year: 1995 }), + ], + }); + render(Library); + await vi.waitFor(() => { + // Year labels appear both as filter options and in game cards + expect(screen.getAllByText("1995").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("1997").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText("1998").length).toBeGreaterThanOrEqual(1); + }); + }); + + it("applies genre filter from props", async () => { + api.listGames.mockResolvedValue({ + games: [ + makeGame({ id: "g1", title: "RPG Game", genre: "RPG" }), + makeGame({ id: "g2", title: "Adventure Game", genre: "Adventure" }), + ], + }); + render(Library, { props: { genreFilter: "RPG" } }); + await vi.waitFor(() => { + expect(screen.getByText("RPG Game")).toBeTruthy(); + expect(screen.queryByText("Adventure Game")).toBeNull(); + }); + }); + + it("applies year filter from props", async () => { + api.listGames.mockResolvedValue({ + games: [ + makeGame({ id: "g1", title: "Old Game", year: 1993 }), + makeGame({ id: "g2", title: "New Game", year: 1997 }), + ], + }); + render(Library, { props: { yearFilter: "1997" } }); + await vi.waitFor(() => { + expect(screen.getByText("New Game")).toBeTruthy(); + expect(screen.queryByText("Old Game")).toBeNull(); + }); + }); + + it("shows filter-empty message when no filter options exist", async () => { + api.listGames.mockResolvedValue({ + games: [ + makeGame({ id: "g1", genre: null, year: null }), + makeGame({ id: "g2", genre: null, year: null }), + ], + }); + render(Library); + await vi.waitFor(() => { + const dashes = screen.getAllByText("—"); + expect(dashes.length).toBeGreaterThanOrEqual(2); + }); + }); + }); +}); diff --git a/frontend/src/pages/__tests__/Play.test.js b/frontend/src/pages/__tests__/Play.test.js new file mode 100644 index 0000000..eafe9d8 --- /dev/null +++ b/frontend/src/pages/__tests__/Play.test.js @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/svelte"; +import Play from "../Play.svelte"; + +// Mock the API module that Play imports +vi.mock("../../lib/api.js", () => ({ + getGame: vi.fn(), + bundleUrl: vi.fn(), + setupBundleUrl: vi.fn(), +})); + +// Mock router +vi.mock("../../lib/router.js", () => ({ + push: vi.fn(), +})); + +import * as api from "../../lib/api.js"; + +function makeGame(overrides = {}) { + return { + id: "game-1", + title: "Commander Keen", + platform: "dos", + ready: true, + ...overrides, + }; +} + +describe("Play", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("shows loading state initially", () => { + api.getGame.mockReturnValue(new Promise(() => {})); + render(Play, { props: { id: "game-1" } }); + expect(screen.getByText("Loading game data...")).toBeTruthy(); + }); + + it("shows not-ready state when game is not processed", async () => { + api.getGame.mockResolvedValue(makeGame({ ready: false })); + render(Play, { props: { id: "game-1" } }); + await vi.waitFor(() => { + expect(screen.getByText("Not ready")).toBeTruthy(); + }); + }); + + it("shows unsupported warning for Windows games", async () => { + api.getGame.mockResolvedValue( + makeGame({ platform: "windows", ready: true }) + ); + render(Play, { props: { id: "game-1" } }); + await vi.waitFor(() => { + expect(screen.getByText("Windows game")).toBeTruthy(); + }); + }); + + it("shows error box when game fetch fails", async () => { + api.getGame.mockRejectedValue(new Error("Game not found")); + render(Play, { props: { id: "missing" } }); + // The error should appear in the overlay as well + await vi.waitFor(() => { + expect(screen.getByText("⚠️")).toBeTruthy(); + }); + }); + + it("shows emulator booting state for ready DOS games", async () => { + api.getGame.mockResolvedValue( + makeGame({ platform: "dos", ready: true }) + ); + api.bundleUrl.mockReturnValue("/games/game-1.jsdos"); + render(Play, { props: { id: "game-1" } }); + await vi.waitFor(() => { + expect(screen.getByText("Starting emulator...")).toBeTruthy(); + }); + }); +}); diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js new file mode 100644 index 0000000..1c8d7f1 --- /dev/null +++ b/frontend/svelte.config.js @@ -0,0 +1,5 @@ +export default { + compilerOptions: { + runes: true, + }, +}; diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..f599bcc --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,27 @@ +/// +import { defineConfig } from "vite"; +import { svelte } from "@sveltejs/vite-plugin-svelte"; + +export default defineConfig({ + plugins: [svelte()], + server: { + port: 5173, + proxy: { + "/api": "http://localhost:8765", + "/games": "http://localhost:8765", + "/artwork": "http://localhost:8765", + }, + }, + build: { + outDir: "dist", + emptyOutDir: true, + }, + resolve: { + conditions: process.env.VITEST ? ["browser", "module", "import"] : [], + }, + test: { + environment: "jsdom", + globals: true, + include: ["src/**/*.test.js"], + }, +}); diff --git a/images/game.jpeg b/images/game.jpeg new file mode 100644 index 0000000..a7551de Binary files /dev/null and b/images/game.jpeg differ diff --git a/images/library.jpeg b/images/library.jpeg new file mode 100644 index 0000000..f101c2d Binary files /dev/null and b/images/library.jpeg differ diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..8b0b48d --- /dev/null +++ b/pom.xml @@ -0,0 +1,155 @@ + + + 4.0.0 + + org.dostalgia + dostalgia + 0.1.0 + + + 21 + 21 + 21 + UTF-8 + 3.19.2 + io.quarkus.platform + + false + + false + + + + + + ${quarkus.platform.group-id} + quarkus-bom + ${quarkus.platform.version} + pom + import + + + + + + + + io.quarkus + quarkus-rest + + + + io.quarkus + quarkus-rest-jackson + + + + + io.quarkus + quarkus-vertx-http + + + + io.quarkus + quarkus-arc + + + + io.quarkus + quarkus-junit5 + test + + + + + + + ${quarkus.platform.group-id} + quarkus-maven-plugin + ${quarkus.platform.version} + true + + + + build + generate-code + + + + + + + org.apache.maven.plugins + maven-resources-plugin + 3.3.1 + + + copy-frontend + generate-resources + copy-resources + + ${project.build.directory}/classes/META-INF/resources + + + frontend/dist + false + + + + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.5.0 + + + npm-install + initialize + exec + + frontend + npm + + ci + --prefer-offline + + ${skipFrontend} + + + + frontend-build + generate-resources + exec + + frontend + npm + + run + build + + ${skipFrontend} + + + + frontend-test + test + exec + + frontend + npm + + test + + ${skipFrontendTests} + + + + + + + diff --git a/src/main/java/org/dostalgia/CdImageService.java b/src/main/java/org/dostalgia/CdImageService.java new file mode 100644 index 0000000..68ebeed --- /dev/null +++ b/src/main/java/org/dostalgia/CdImageService.java @@ -0,0 +1,106 @@ +package org.dostalgia; + +import jakarta.enterprise.context.ApplicationScoped; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.logging.Logger; + +/** + * CD image utilities: CUE sheet repair and CD image discovery in directories and ZIPs. + */ +@ApplicationScoped +public class CdImageService { + + private static final Logger LOG = Logger.getLogger(CdImageService.class.getName()); + private static final java.util.Set CD_EXT = java.util.Set.of( + ".iso", ".cue", ".img", ".ccd", ".bin" + ); + + /** + * Fix .cue files that reference non-existent data files. + * CloneCD rips often generate a .cue referencing a .bin, but the actual file is .img. + */ + public void fixCueReferences(Path extractDir) throws IOException { + try (var walk = Files.walk(extractDir)) { + walk.filter(Files::isRegularFile) + .filter(f -> f.getFileName().toString().toLowerCase().endsWith(".cue")) + .forEach(f -> { + try { fixOneCue(f); } + catch (IOException e) { + LOG.warning("Failed to fix cue file " + f + ": " + e.getMessage()); + } + }); + } + } + + private void fixOneCue(Path cueFile) throws IOException { + String content = Files.readString(cueFile); + Path dir = cueFile.getParent(); + + var m = java.util.regex.Pattern + .compile("FILE\\s+\"([^\"]+)\"", java.util.regex.Pattern.CASE_INSENSITIVE) + .matcher(content); + if (!m.find()) return; + + String referenced = m.group(1); + Path refPath = dir.resolve(referenced); + if (Files.exists(refPath)) return; + + Path replacement = findDataFile(dir); + if (replacement == null) return; + + String actualName = replacement.getFileName().toString(); + content = content.substring(0, m.start(1)) + actualName + content.substring(m.end(1)); + Files.writeString(cueFile, content); + LOG.info("Fixed cue " + cueFile.getFileName() + ": '" + referenced + "' → '" + actualName + "'"); + } + + private Path findDataFile(Path dir) throws IOException { + try (var files = Files.list(dir)) { + return files.filter(Files::isRegularFile) + .filter(f -> { + String n = f.getFileName().toString().toLowerCase(); + return n.endsWith(".img") || n.endsWith(".bin"); + }) + .findFirst() + .orElse(null); + } + } + + /** Find mountable CD image files in a directory. Returns sorted relative paths. */ + public java.util.List findInDirectory(Path dir) throws IOException { + java.util.List cds = new java.util.ArrayList<>(); + try (var walk = Files.walk(dir)) { + walk.filter(Files::isRegularFile).forEach(f -> { + String name = f.getFileName().toString().toLowerCase(); + int dot = name.lastIndexOf('.'); + if (dot >= 0 && CD_EXT.contains(name.substring(dot))) { + cds.add(dir.relativize(f).toString().replace('\\', '/')); + } + }); + } + cds.sort(String::compareTo); + return cds; + } + + /** Find CD image files inside a .jsdos ZIP bundle. */ + public java.util.List findInBundle(Path bundlePath) throws IOException { + java.util.List cds = new java.util.ArrayList<>(); + try (var zis = new java.util.zip.ZipInputStream(Files.newInputStream(bundlePath))) { + java.util.zip.ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + if (entry.isDirectory()) continue; + String name = entry.getName().toLowerCase(); + int dot = name.lastIndexOf('.'); + if (dot >= 0 && CD_EXT.contains(name.substring(dot))) { + cds.add(entry.getName()); + } + zis.closeEntry(); + } + } + cds.sort(String::compareTo); + return cds; + } +} diff --git a/src/main/java/org/dostalgia/ConfigBuilder.java b/src/main/java/org/dostalgia/ConfigBuilder.java new file mode 100644 index 0000000..5aa8298 --- /dev/null +++ b/src/main/java/org/dostalgia/ConfigBuilder.java @@ -0,0 +1,223 @@ +package org.dostalgia; + +import jakarta.enterprise.context.ApplicationScoped; + +import java.nio.charset.StandardCharsets; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Single source of truth for all DOSBox configuration generation. + */ +@ApplicationScoped +public class ConfigBuilder { + + /** + * Build a dosbox.conf for a game with a known executable. + * @param relExe relative path to the executable inside the ZIP (e.g. "FALLOUT.EXE" or "FALLOUT/FALLOUT.EXE") + * @param cdImages relative paths to CD image files inside the ZIP + */ + public String buildDosboxConf(String relExe, List cdImages) { + var parts = splitExePath(relExe); + String dir = parts.dir(); + String exe = parts.exe(); + + return DOSBOX_CONF_TEMPLATE + .replace("${CD_MOUNTS}", buildCdMounts(cdImages)) + .replace("${EXEC_PATH}", buildExecPath(dir, exe)); + } + + /** Build dosbox.conf as UTF-8 bytes (for ZIP stream operations). */ + public byte[] buildDosboxConfBytes(String relExe, List cdImages) { + return buildDosboxConf(relExe, cdImages).getBytes(StandardCharsets.UTF_8); + } + + /** + * Build jsdos.json for js-dos v8 with the given executable path. + * js-dos v8 uses jsdos.json autoexec.script instead of the dosbox.conf [autoexec] section. + */ + public byte[] buildJsdosJson(String relExe, List cdImages) { + var parts = splitExePath(relExe); + String script = buildAutoexecScript(parts.dir(), parts.exe(), cdImages); + return buildJsdosJsonRaw(script); + } + + /** Build dosbox.conf for a CD-only game (no filesystem executables). */ + public String buildCdOnlyDosboxConf(List cdImages) { + return CD_ONLY_DOSBOX_CONF_TEMPLATE + .replace("${CD_MOUNTS}", buildCdMounts(cdImages)); + } + + /** Build jsdos.json for a CD-only game. */ + public byte[] buildCdOnlyJsdosJson(List cdImages) { + String script = buildCdMountScript(cdImages) + "c:\n"; + return buildJsdosJsonRaw(script); + } + + /** Split "dir/file.exe" into (dir, exe). Returns (null, file) if no directory separator. */ + public static ExeParts splitExePath(String relExe) { + if (relExe == null || relExe.isBlank()) { + return new ExeParts(null, ""); + } + int idx = relExe.replace('\\', '/').lastIndexOf('/'); + if (idx >= 0) { + return new ExeParts(relExe.substring(0, idx), relExe.substring(idx + 1)); + } + return new ExeParts(null, relExe); + } + + /** Escape a string for embedding inside a JSON string value. */ + public static String escapeJson(String s) { + return s.replace("\\", "\\\\").replace("\"", "\\\""); + } + + public record ExeParts(String dir, String exe) {} + + private static final String DOSBOX_CONF_TEMPLATE = """ + [sdl] + autolock=true + usescancodes=true + output=surface + + [cpu] + core=auto + cycles=auto + + [mixer] + nosound=false + rate=44100 + + [sblaster] + sbtype=sb16 + irq=7 + dma=1 + hdma=5 + + [dos] + xms=true + ems=true + umb=true + + [dosbox] + memsize=64 + + [autoexec] + @echo off + mount c . + ${CD_MOUNTS}c: + ${EXEC_PATH} + """; + + private static final String CD_ONLY_DOSBOX_CONF_TEMPLATE = """ + [sdl] + autolock=true + usescancodes=true + output=surface + + [cpu] + core=auto + cycles=auto + + [mixer] + nosound=false + rate=44100 + + [sblaster] + sbtype=sb16 + irq=7 + dma=1 + hdma=5 + + [dos] + xms=true + ems=true + umb=true + + [dosbox] + memsize=64 + + [autoexec] + @echo off + mount c . + ${CD_MOUNTS}c: + echo. + echo This game runs from the CD-ROM. + echo Type D: and press Enter, then look for the game executable (e.g. DOTT.EXE). + """; + + /** + * Build the CD mount lines for the autoexec section. + * Mounts ALL CD images, deduplicating by parent directory. + * Skips descriptor formats (.cue, .ccd) when a data format + * (.img, .iso, .bin) exists in the same directory. + */ + private static String buildCdMounts(List cdImages) { + if (cdImages == null || cdImages.isEmpty()) return ""; + // Deduplicate by parent directory — prefer data formats over descriptors + Set seenDirs = new HashSet<>(); + StringBuilder sb = new StringBuilder(); + char driveLetter = 'D'; + for (String img : cdImages) { + int slashIdx = img.lastIndexOf('/'); + String parentDir = slashIdx >= 0 ? img.substring(0, slashIdx) : ""; + if (seenDirs.contains(parentDir)) continue; + String imgLower = img.toLowerCase(); + // Skip descriptor formats if a data format exists in the same directory + if (imgLower.endsWith(".cue") || imgLower.endsWith(".ccd")) { + boolean hasData = cdImages.stream().anyMatch(i -> { + int is = i.lastIndexOf('/'); + String ip = is >= 0 ? i.substring(0, is) : ""; + String il = i.toLowerCase(); + return ip.equals(parentDir) + && (il.endsWith(".img") || il.endsWith(".iso") || il.endsWith(".bin")); + }); + if (hasData) continue; + } + seenDirs.add(parentDir); + String flags = imgLower.endsWith(".bin") ? " -t cdrom -fs iso" : " -t cdrom"; + sb.append("imgmount ").append(driveLetter).append(" \"") + .append(img).append("\"").append(flags).append("\n"); + driveLetter++; + } + return sb.toString(); + } + + private static String buildExecPath(String dir, String exe) { + if (dir == null) return exe + "\n"; + return "path=c:\\\ncd " + dir + "\n" + exe + "\n"; + } + + private static String buildCdMountScript(List cdImages) { + StringBuilder script = new StringBuilder(); + script.append("mount c .\n"); + char driveLetter = 'D'; + for (String img : cdImages) { + String imgLower = img.toLowerCase(); + String flags = imgLower.endsWith(".bin") && !imgLower.endsWith(".cue") + ? " -t cdrom -fs iso" : " -t cdrom"; + script.append("imgmount ").append(driveLetter).append(" \"") + .append(img).append("\"").append(flags).append("\n"); + driveLetter++; + } + return script.toString(); + } + + private static String buildAutoexecScript(String dir, String exe, List cdImages) { + StringBuilder script = new StringBuilder(buildCdMountScript(cdImages)); + script.append("c:\n"); + if (dir != null) { + script.append("path=c:\\\n"); + script.append("cd ").append(dir).append("\n"); + } + script.append(exe); + return script.toString(); + } + + private static byte[] buildJsdosJsonRaw(String script) { + String json = "{\"autoexec\":{\"options\":{\"script\":{\"value\":\"" + + escapeJson(script) + "\"}}}," + + "\"output\":{\"options\":{\"autolock\":{\"value\":true}}}}"; + return json.getBytes(StandardCharsets.UTF_8); + } +} diff --git a/src/main/java/org/dostalgia/ConfigPatcher.java b/src/main/java/org/dostalgia/ConfigPatcher.java new file mode 100644 index 0000000..739e5dd --- /dev/null +++ b/src/main/java/org/dostalgia/ConfigPatcher.java @@ -0,0 +1,152 @@ +package org.dostalgia; + +import jakarta.enterprise.context.ApplicationScoped; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashSet; +import java.util.Set; +import java.util.logging.Logger; +import java.util.regex.Pattern; + +/** + * Detects and fixes hardcoded absolute DOS paths in game config files. + * Handles the common case where game archives contain paths like + * C:\\FALLOUT1\\MASTER.DAT that break after directory flattening. + */ +@ApplicationScoped +public class ConfigPatcher { + + private static final Logger LOG = Logger.getLogger(ConfigPatcher.class.getName()); + private static final Pattern DRIVE_PATH = Pattern.compile("[A-Za-z]:([\\\\/])[^\\s\"\\r\\n]+"); + + /** Scan and fix absolute paths in all text files in the given directory. */ + public void fixAbsolutePaths(Path extractDir) throws IOException { + try (var walk = Files.walk(extractDir)) { + walk.filter(Files::isRegularFile) + .filter(f -> { + try { return isSmallTextFile(f); } + catch (IOException e) { return false; } + }) + .forEach(f -> { + try { + String text = Files.readString(f, StandardCharsets.ISO_8859_1); + String patched = patchText(text, extractDir); + if (!patched.equals(text)) { + Files.writeString(f, patched, StandardCharsets.ISO_8859_1); + LOG.info("Patched absolute paths in " + f.getFileName()); + } + } catch (IOException e) { + LOG.warning("Failed to patch " + f.getFileName() + ": " + e.getMessage()); + } + }); + } + } + + private static boolean isSmallTextFile(Path f) throws IOException { + long size = Files.size(f); + if (size > 100 * 1024 || size == 0) return false; + try (var is = Files.newInputStream(f)) { + byte[] head = new byte[(int) Math.min(size, 4096)]; + int read = is.readNBytes(head, 0, head.length); + for (int i = 0; i < read; i++) { + if (head[i] == 0) return false; + } + } + return true; + } + + private static String patchText(String text, Path extractDir) { + var m = DRIVE_PATH.matcher(text); + var sb = new StringBuilder(); + int lastEnd = 0; + Set seenPrefixes = new HashSet<>(); + + while (m.find()) { + int start = m.start(); + sb.append(text, lastEnd, start); + + String fullPath = m.group(); + String drive = fullPath.substring(0, 2); + if (!"C:".equalsIgnoreCase(drive)) { + sb.append(fullPath); + lastEnd = m.end(); + continue; + } + + String replacement = findReplacement(fullPath, extractDir, seenPrefixes); + sb.append(replacement); + lastEnd = m.end(); + } + sb.append(text.substring(lastEnd)); + return sb.toString(); + } + + private static String findReplacement(String fullPath, Path extractDir, Set seenPrefixes) { + String drivePrefix = fullPath.substring(0, 2); + char sep = fullPath.charAt(2); + String splitRegex = sep == '\\' ? "\\\\" : "/"; + String relPath = fullPath.substring(3); + String[] parts = relPath.split(splitRegex, -1); + + for (int skip = 0; skip < parts.length; skip++) { + StringBuilder suffix = new StringBuilder(); + for (int j = skip; j < parts.length; j++) { + if (j > skip) suffix.append(sep); + suffix.append(parts[j]); + } + String suffixStr = suffix.toString(); + if (suffixStr.isEmpty()) continue; + + if (existsIgnoreCase(extractDir, suffixStr)) { + if (skip == 0) return fullPath; + + StringBuilder stale = new StringBuilder(drivePrefix + sep); + for (int k = 0; k < skip; k++) { + if (k > 0) stale.append(sep); + stale.append(parts[k]); + } + stale.append(sep); + + String staleStr = stale.toString(); + if (seenPrefixes.contains(staleStr)) { + return "." + sep + suffixStr; + } + seenPrefixes.add(staleStr); + return "." + sep + suffixStr; + } + } + return fullPath; + } + + private static boolean existsIgnoreCase(Path base, String relativePath) { + char sep = relativePath.contains("\\") ? '\\' : '/'; + String[] parts = relativePath.split(sep == '\\' ? "\\\\" : "/", -1); + Path current = base; + try { + for (int i = 0; i < parts.length; i++) { + String part = parts[i]; + if (part.isEmpty()) { + if (i == parts.length - 1) return Files.isDirectory(current); + continue; + } + Path found = null; + try (var dirStream = Files.list(current)) { + for (Path entry : dirStream.toList()) { + if (entry.getFileName().toString().equalsIgnoreCase(part)) { + found = entry; + break; + } + } + } + if (found == null) return false; + current = found; + } + } catch (IOException e) { + return false; + } + return true; + } +} diff --git a/src/main/java/org/dostalgia/ExecutableDetector.java b/src/main/java/org/dostalgia/ExecutableDetector.java new file mode 100644 index 0000000..b74c36f --- /dev/null +++ b/src/main/java/org/dostalgia/ExecutableDetector.java @@ -0,0 +1,137 @@ +package org.dostalgia; + +import jakarta.annotation.Nonnull; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import java.io.IOException; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Set; + +/** + * Detects game executables within extracted archives. + */ +@ApplicationScoped +public class ExecutableDetector { + + @Inject + PlatformDetector platform; + + private static final Set SKIP_EXE_NAMES = Set.of( + "dos4gw", "dos32a", "dos4gw2", "pmode", "pmodew", "cwsdpmi", + "emm386", "himem", "himemx", "debug", + "uninst", "uninstall", "uninstaller", + "pksfx", "pkzip", "pkunzip", "sfx", "makesfx", + "unzip", "zip", "zip2exe", "arj", "arj32", + "lha", "lha32", "lzh", "rar", "unrar", + "ace", "unace", "zoo", "arc", "ha", "cab" + ); + + public List findAll(Path dir) throws IOException { + return FileUtils.findFilesByExtensions(dir, Set.of(".exe", ".com", ".bat")); + } + + public String findMain(Path dir) throws IOException { + record Candidate(int depth, boolean isInstaller, long size, String path, String platform) {} + List candidates = new ArrayList<>(); + + Files.walkFileTree(dir, new SimpleFileVisitor<>() { + @Override + public @Nonnull FileVisitResult visitFile(@Nonnull Path f, @Nonnull BasicFileAttributes a) { + String name = f.getFileName().toString().toLowerCase(); + if (!name.endsWith(".exe") && !name.endsWith(".com") && !name.endsWith(".bat")) + return FileVisitResult.CONTINUE; + + int dot = name.lastIndexOf('.'); + String stem = dot >= 0 ? name.substring(0, dot) : name; + if (SKIP_EXE_NAMES.contains(stem)) + return FileVisitResult.CONTINUE; + + if (isLikelySelfExtractor(f)) + return FileVisitResult.CONTINUE; + + boolean installer = name.contains("install") || name.contains("setup") || name.contains("config"); + int depth = f.getNameCount() - dir.getNameCount(); + String detectedPlatform = platform.detect(f); + candidates.add(new Candidate(depth, installer, a.size(), f.toString(), detectedPlatform)); + return FileVisitResult.CONTINUE; + } + }); + + if (candidates.isEmpty()) return null; + + candidates.sort(Comparator + .comparingInt((Candidate c) -> c.isInstaller() ? 1 : 0) + .thenComparingInt(c -> { + if ("dos".equals(c.platform())) return 0; + if (c.platform() == null) return 1; + return 2; + }) + .thenComparingLong(c -> -c.size()) + .thenComparingInt(Candidate::depth)); + + return candidates.getFirst().path(); + } + + public String findSetup(Path dir) throws IOException { + record Candidate(int depth, boolean isWindows, long size, String path) {} + List candidates = new ArrayList<>(); + List patterns = List.of("setup", "setmain", "install", "config", "custom"); + + Files.walkFileTree(dir, new SimpleFileVisitor<>() { + @Override + public @Nonnull FileVisitResult visitFile(@Nonnull Path f, @Nonnull BasicFileAttributes a) { + String name = f.getFileName().toString().toLowerCase(); + String stem = name.contains(".") ? name.substring(0, name.lastIndexOf('.')) : name; + if (!name.endsWith(".exe") && !name.endsWith(".com") && !name.endsWith(".bat")) + return FileVisitResult.CONTINUE; + for (String pat : patterns) { + if (stem.equals(pat) || stem.startsWith(pat + ".") || stem.startsWith(pat + "-")) { + boolean isWin = "windows".equals(platform.detect(f)); + candidates.add(new Candidate( + f.getNameCount() - dir.getNameCount(), isWin, a.size(), f.toString())); + break; + } + } + return FileVisitResult.CONTINUE; + } + }); + + if (candidates.isEmpty()) return null; + candidates.sort(Comparator + .comparingInt((Candidate c) -> c.isWindows() ? 1 : 0) // prefer DOS over Windows + .thenComparingInt(Candidate::depth) + .thenComparingLong(Candidate::size)); + return candidates.getFirst().path(); + } + + public static boolean isLikelySelfExtractor(Path exePath) { + try (var fis = Files.newInputStream(exePath)) { + byte[] buf = new byte[32768]; + int read = fis.readNBytes(buf, 0, buf.length); + if (read < 0x40) return false; + if (buf[0] != 0x4D || buf[1] != 0x5A) return false; + + String content = new String(buf, 0, read, StandardCharsets.ISO_8859_1); + if (content.contains("PKZIP") || content.contains("PKSFX")) return true; + + for (int i = 0x40; i < read - 4; i++) { + if (buf[i] == 0x50 && (buf[i+1] & 0xFF) == 0x4B) { + int sig = (buf[i+2] & 0xFF) | ((buf[i+3] & 0xFF) << 8); + if (sig == 0x0403 || sig == 0x0201 || sig == 0x0605) return true; + } + } + return false; + } catch (IOException e) { + return false; + } + } +} diff --git a/src/main/java/org/dostalgia/FileUtils.java b/src/main/java/org/dostalgia/FileUtils.java new file mode 100644 index 0000000..27808b9 --- /dev/null +++ b/src/main/java/org/dostalgia/FileUtils.java @@ -0,0 +1,60 @@ +package org.dostalgia; + +import jakarta.annotation.Nonnull; + +import java.io.IOException; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +/** Shared file-system utilities. */ +public final class FileUtils { + private FileUtils() {} + + /** Recursively delete a directory tree (walk in reverse order so dirs are empty when deleted). */ + public static void deleteDirectory(final Path dir) throws IOException { + if (!Files.exists(dir)) return; + Files.walkFileTree(dir, new SimpleFileVisitor<>() { + @Override + public @Nonnull FileVisitResult visitFile(@Nonnull final Path f, @Nonnull final BasicFileAttributes a) throws IOException { + Files.delete(f); + return FileVisitResult.CONTINUE; + } + + @Override + public @Nonnull FileVisitResult postVisitDirectory(@Nonnull final Path d, final IOException e) throws IOException { + Files.delete(d); + return FileVisitResult.CONTINUE; + } + }); + } + + /** + * Walk a directory tree and collect relative paths of files whose extension matches + * one of the given extensions. Extensions should include the dot, e.g. {@code ".exe"}. + * Results are sorted alphabetically. + */ + public static List findFilesByExtensions(Path dir, Set extensions) throws IOException { + List result = new ArrayList<>(); + Files.walkFileTree(dir, new SimpleFileVisitor<>() { + @Override + public @Nonnull FileVisitResult visitFile(@Nonnull Path f, @Nonnull BasicFileAttributes a) { + String name = f.getFileName().toString().toLowerCase(); + for (String ext : extensions) { + if (name.endsWith(ext)) { + result.add(dir.relativize(f).toString().replace('\\', '/')); + break; + } + } + return FileVisitResult.CONTINUE; + } + }); + result.sort(String::compareTo); + return result; + } +} diff --git a/src/main/java/org/dostalgia/Game.java b/src/main/java/org/dostalgia/Game.java new file mode 100644 index 0000000..c81c618 --- /dev/null +++ b/src/main/java/org/dostalgia/Game.java @@ -0,0 +1,126 @@ +package org.dostalgia; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import java.time.Instant; +import java.util.List; + +/** Represents a single DOS game, stored as data/games/{id}/game.json */ +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class Game { + private String id; + private String title; + private Integer year; + private String genre; + private String developer; + private String publisher; + private String description; + private Double rating; + private String bundleFile; + private String setupExe; + /** Game bundle file size in bytes. Populated dynamically at load time (not persisted). */ + private Long bundleSize; + /** Currently selected main executable (relative path inside the bundle). */ + private String executable; + /** All discoverable executables in the bundle (for the UI picker). */ + private List executables; + /** Platform type: "dos" for pure DOS, "windows" for Windows 3.x/9x+ */ + private String platform; + private Integer igdbId; + private String igdbCoverId; + private boolean hasCover; + private boolean hasScreenshots; + private List screenshots; + private List videos; + private boolean ready; + private Instant createdAt; + private Instant updatedAt; + + public Game() {} + + public Game(String id, String title) { + this.id = id; + this.title = title; + this.ready = false; + this.createdAt = Instant.now(); + this.updatedAt = Instant.now(); + } + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + public String getTitle() { return title; } + public void setTitle(String title) { this.title = title; } + + public Integer getYear() { return year; } + public void setYear(Integer year) { this.year = year; } + + public String getGenre() { return genre; } + public void setGenre(String genre) { this.genre = genre; } + + public String getDeveloper() { return developer; } + public void setDeveloper(String developer) { this.developer = developer; } + + public String getPublisher() { return publisher; } + public void setPublisher(String publisher) { this.publisher = publisher; } + + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + + public Double getRating() { return rating; } + public void setRating(Double rating) { this.rating = rating; } + + public String getBundleFile() { return bundleFile; } + public void setBundleFile(String bundleFile) { this.bundleFile = bundleFile; } + + public String getSetupExe() { return setupExe; } + public void setSetupExe(String setupExe) { this.setupExe = setupExe; } + + public Long getBundleSize() { return bundleSize; } + public void setBundleSize(Long bundleSize) { this.bundleSize = bundleSize; } + + public String getExecutable() { return executable; } + public void setExecutable(String executable) { this.executable = executable; } + + public List getExecutables() { return executables; } + public void setExecutables(List executables) { this.executables = executables; } + + /** Returns true if a SETUP.EXE (or variant) was detected in the game archive. */ + public boolean isHasSetup() { return setupExe != null && !setupExe.isBlank(); } + + public String getPlatform() { return platform; } + public void setPlatform(String platform) { this.platform = platform; } + + /** Returns true if the game is a pure DOS executable that can run natively in DOSBox. */ + public boolean isPlayable() { + return ready && (platform == null || "dos".equals(platform)); + } + + public Integer getIgdbId() { return igdbId; } + public void setIgdbId(Integer igdbId) { this.igdbId = igdbId; } + + public String getIgdbCoverId() { return igdbCoverId; } + public void setIgdbCoverId(String igdbCoverId) { this.igdbCoverId = igdbCoverId; } + + public boolean isHasCover() { return hasCover; } + public void setHasCover(boolean hasCover) { this.hasCover = hasCover; } + + public boolean isHasScreenshots() { return hasScreenshots; } + public void setHasScreenshots(boolean hasScreenshots) { this.hasScreenshots = hasScreenshots; } + + public List getScreenshots() { return screenshots; } + public void setScreenshots(List screenshots) { this.screenshots = screenshots; } + + public List getVideos() { return videos; } + public void setVideos(List videos) { this.videos = videos; } + + public boolean isReady() { return ready; } + public void setReady(boolean ready) { this.ready = ready; } + + public Instant getCreatedAt() { return createdAt; } + public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; } + + public Instant getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(Instant updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/src/main/java/org/dostalgia/GameController.java b/src/main/java/org/dostalgia/GameController.java new file mode 100644 index 0000000..97fd2ce --- /dev/null +++ b/src/main/java/org/dostalgia/GameController.java @@ -0,0 +1,183 @@ +package org.dostalgia; + +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.PATCH; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.StreamingOutput; +import java.nio.file.NoSuchFileException; +import java.nio.file.Files; +import java.util.Map; + +@Path("/api/games") +@Produces(MediaType.APPLICATION_JSON) +public class GameController { + + @Inject + GameService svc; + + @GET + public Response list( + @QueryParam("search") String search, + @QueryParam("genre") String genre, + @QueryParam("year") Integer year, + @QueryParam("limit") @DefaultValue("50") int limit, + @QueryParam("offset") @DefaultValue("0") int offset + ) { + try { + var all = svc.list(); + + if (search != null && !search.isBlank()) { + all = all.stream() + .filter(g -> g.getTitle().toLowerCase().contains(search.toLowerCase())) + .toList(); + } + if (genre != null && !genre.isBlank()) { + all = all.stream() + .filter(g -> genre.equalsIgnoreCase(g.getGenre())) + .toList(); + } + if (year != null) { + all = all.stream() + .filter(g -> year.equals(g.getYear())) + .toList(); + } + + int total = all.size(); + int from = Math.min(offset, total); + int to = Math.min(offset + limit, total); + var page = all.subList(from, to); + + return Response.ok(Map.of("games", page, "total", total)).build(); + } catch (Exception e) { + return Response.serverError().entity(Map.of("error", e.getMessage())).build(); + } + } + + @GET + @Path("/{id}") + public Response get(@PathParam("id") String id) { + try { + var game = svc.load(id); + return Response.ok(game).build(); + } catch (NoSuchFileException e) { + return Response.status(404).entity(Map.of("error", "Game not found")).build(); + } catch (Exception e) { + return Response.serverError().entity(Map.of("error", e.getMessage())).build(); + } + } + + @PATCH + @Path("/{id}") + @Consumes(MediaType.APPLICATION_JSON) + public Response update(@PathParam("id") String id, Map updates) { + try { + var game = svc.load(id); + + if (updates.containsKey("title")) game.setTitle((String) updates.get("title")); + if (updates.containsKey("year")) game.setYear(asInt(updates.get("year"))); + if (updates.containsKey("genre")) game.setGenre((String) updates.get("genre")); + if (updates.containsKey("developer")) game.setDeveloper((String) updates.get("developer")); + if (updates.containsKey("publisher")) game.setPublisher((String) updates.get("publisher")); + if (updates.containsKey("description")) game.setDescription((String) updates.get("description")); + if (updates.containsKey("rating")) game.setRating(asDouble(updates.get("rating"))); + if (updates.containsKey("platform")) game.setPlatform((String) updates.get("platform")); + + // Changing the executable requires patching the .jsdos bundle + if (updates.containsKey("executable")) { + String newExe = (String) updates.get("executable"); + if (newExe != null && !newExe.isBlank()) { + svc.setExecutable(id, newExe); + } + // Reload after bundle patch to get updated metadata + game = svc.load(id); + } else { + svc.save(game); + } + return Response.ok(game).build(); + } catch (NoSuchFileException e) { + return Response.status(404).entity(Map.of("error", "Game not found")).build(); + } catch (Exception e) { + return Response.serverError().entity(Map.of("error", e.getMessage())).build(); + } + } + + /** + * Stream a setup .jsdos bundle on-the-fly. No disk duplication — + * reads the main bundle and patches config files to point at the + * setup executable (SETUP.EXE, INSTALL.EXE, etc.). + */ + @GET + @Path("/{id}/setup-bundle") + @Produces("application/zip") + public Response setupBundle(@PathParam("id") String id) { + try { + var game = svc.load(id); + if (game.getSetupExe() == null || game.getSetupExe().isBlank()) { + return Response.status(404) + .entity(Map.of("error", "No setup executable configured for this game")) + .build(); + } + String filename = (game.getTitle() != null ? game.getTitle() : id) + ".setup.jsdos"; + StreamingOutput stream = output -> svc.streamSetupBundle(id, output); + return Response.ok(stream) + .header("Content-Disposition", "attachment; filename=\"" + filename + "\"") + .build(); + } catch (NoSuchFileException e) { + return Response.status(404).entity(Map.of("error", "Game not found")).build(); + } catch (Exception e) { + return Response.serverError().entity(Map.of("error", e.getMessage())).build(); + } + } + + @GET + @Path("/{id}/download") + @Produces("application/zip") + public Response download(@PathParam("id") String id) { + try { + var game = svc.load(id); + String filename = (game.getTitle() != null ? game.getTitle() : id) + ".zip"; + var bundlePath = svc.getGamesDir().resolve(game.getBundleFile()); + if (!Files.exists(bundlePath)) { + return Response.status(404).entity(Map.of("error", "Bundle file not found")).build(); + } + StreamingOutput stream = output -> Files.copy(bundlePath, output); + return Response.ok(stream) + .header("Content-Disposition", "attachment; filename=\"" + filename + "\"") + .build(); + } catch (NoSuchFileException e) { + return Response.status(404).entity(Map.of("error", "Game not found")).build(); + } catch (Exception e) { + return Response.serverError().entity(Map.of("error", e.getMessage())).build(); + } + } + + @DELETE + @Path("/{id}") + public Response delete(@PathParam("id") String id) { + try { + svc.delete(id); + return Response.ok(Map.of("status", "deleted")).build(); + } catch (Exception e) { + return Response.serverError().entity(Map.of("error", e.getMessage())).build(); + } + } + + private Integer asInt(Object v) { + if (v instanceof Number n) return n.intValue(); + return null; + } + + private Double asDouble(Object v) { + if (v instanceof Number n) return n.doubleValue(); + return null; + } +} diff --git a/src/main/java/org/dostalgia/GameService.java b/src/main/java/org/dostalgia/GameService.java new file mode 100644 index 0000000..fecd4df --- /dev/null +++ b/src/main/java/org/dostalgia/GameService.java @@ -0,0 +1,265 @@ +package org.dostalgia; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** Manages game metadata stored as JSON files on disk. */ +@ApplicationScoped +public class GameService implements GameStore { + + private final ObjectMapper mapper = new ObjectMapper() + .registerModule(new JavaTimeModule()) + .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .enable(SerializationFeature.INDENT_OUTPUT); + + @Inject + ConfigBuilder config; + + @Inject + CdImageService cdImageService; + + @ConfigProperty(name = "dostalgia.data.dir", defaultValue = "data") + String dataDir; + + Path gamesDir; + + @PostConstruct + void init() { + gamesDir = Path.of(dataDir, "games"); + try { + Files.createDirectories(gamesDir); + Files.createDirectories(Path.of(dataDir, "artwork")); + Files.createDirectories(Path.of(dataDir, "saves")); + } catch (IOException e) { + throw new RuntimeException("Cannot create data directories", e); + } + } + + public Path gameDir(String id) { + return gamesDir.resolve(id); + } + + public Path gameJsonPath(String id) { + return gameDir(id).resolve("game.json"); + } + + /** Load a game by ID. Throws if not found. */ + public Game load(String id) throws IOException { + Path path = gameJsonPath(id); + if (!Files.exists(path)) { + throw new NoSuchFileException("game.json not found for: " + id); + } + Game game = mapper.readValue(path.toFile(), Game.class); + // Detect cover file + game.setHasCover( + Files.exists(gameDir(id).resolve("cover.jpg")) || + Files.exists(gameDir(id).resolve("cover.png")) || + Files.exists(gameDir(id).resolve("cover.webp")) + ); + // Detect bundle size + try { + String bf = game.getBundleFile(); + if (bf != null) { + Path bundlePath = gamesDir.resolve(bf); + if (Files.exists(bundlePath)) { + game.setBundleSize(Files.size(bundlePath)); + } + } + } catch (IOException ignored) {} + return game; + } + + /** Save game metadata to disk. */ + public void save(Game game) throws IOException { + game.setUpdatedAt(Instant.now()); + if (game.getCreatedAt() == null) { + game.setCreatedAt(Instant.now()); + } + Files.createDirectories(gameDir(game.getId())); + mapper.writeValue(gameJsonPath(game.getId()).toFile(), game); + } + + /** List all games, sorted by title. */ + public List list() throws IOException { + List games = new ArrayList<>(); + if (!Files.isDirectory(gamesDir)) return games; + + try (var stream = Files.list(gamesDir)) { + for (Path dir : (Iterable) stream::iterator) { + if (!Files.isDirectory(dir)) continue; + try { + Game g = load(dir.getFileName().toString()); + games.add(g); + } catch (Exception ignored) { + // skip broken entries + } + } + } + + games.sort(Comparator.comparing(Game::getTitle)); + return games; + } + + /** Delete a game and all its files. */ + public void delete(String id) throws IOException { + FileUtils.deleteDirectory(gameDir(id)); + // Clean up old flat .jsdos locations (pre-game-dir layout — backward compat) + Files.deleteIfExists(gamesDir.resolve(id + ".jsdos")); + Files.deleteIfExists(gamesDir.resolve(id + ".setup.jsdos")); + } + + /** Sanitize a string for use as a game directory ID. */ + public static String sanitizeId(String s) { + StringBuilder sb = new StringBuilder(); + for (char c : s.toCharArray()) { + if (c >= 'a' && c <= 'z') sb.append(c); + else if (c >= 'A' && c <= 'Z') sb.append((char)(c + 32)); + else if (c >= '0' && c <= '9') sb.append(c); + else if (c == ' ' || c == '-' || c == '_') sb.append('-'); + } + String result = sb.toString().replaceAll("^-+|-+$", ""); + return result.isEmpty() ? "unknown" : result; + } + + /** + * Patch the .jsdos bundle to use a different main executable. + * Transactional: patches into a temp file, then atomically replaces on success. + */ + public void setExecutable(String id, String executable) throws IOException { + Game game = load(id); + String bundleFile = game.getBundleFile(); + if (bundleFile == null) { + throw new IOException("Game has no bundle file"); + } + + Path bundlePath = gamesDir.resolve(bundleFile); + if (!Files.exists(bundlePath)) { + throw new NoSuchFileException("Bundle not found: " + bundleFile); + } + + // Build new config entries — preserve any CD images in the bundle + List cdImages = cdImageService.findInBundle(bundlePath); + byte[] newDosboxConf = config.buildDosboxConfBytes(executable, cdImages); + byte[] newJsdosJson = config.buildJsdosJson(executable, cdImages); + + // Transactional patch: write to temp, detect platform, then move + Path tmpPath = bundlePath.resolveSibling(bundlePath.getFileName() + ".tmp"); + try { + try (var zis = new java.util.zip.ZipInputStream(Files.newInputStream(bundlePath)); + var zos = new java.util.zip.ZipOutputStream(Files.newOutputStream(tmpPath))) { + + java.util.zip.ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + String name = entry.getName(); + if (".jsdos/dosbox.conf".equals(name)) { + zos.putNextEntry(new java.util.zip.ZipEntry(name)); + zos.write(newDosboxConf); + zos.closeEntry(); + } else if (".jsdos/jsdos.json".equals(name)) { + zos.putNextEntry(new java.util.zip.ZipEntry(name)); + zos.write(newJsdosJson); + zos.closeEntry(); + } else { + zos.putNextEntry(new java.util.zip.ZipEntry(name)); + zis.transferTo(zos); + zos.closeEntry(); + } + zis.closeEntry(); + } + } + + // Detect platform from the new bundle before committing + String detectedPlatform = detectPlatformInBundle(executable, tmpPath); + + // Atomic move on success + Files.move(tmpPath, bundlePath, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + + // Update metadata + game.setExecutable(executable); + game.setPlatform(detectedPlatform); + save(game); + + } catch (Exception e) { + // Clean up temp file on failure — leave original untouched + try { Files.deleteIfExists(tmpPath); } catch (IOException ignored) {} + throw e instanceof IOException ioe ? ioe : new IOException(e); + } + } + + /** Detect platform for an executable inside a .jsdos ZIP bundle. */ + private String detectPlatformInBundle(String relExe, Path bundlePath) throws IOException { + try (var zis = new java.util.zip.ZipInputStream(Files.newInputStream(bundlePath))) { + java.util.zip.ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + if (entry.getName().equals(relExe.replace('\\', '/'))) { + return PlatformDetector.detectFromBytes(PlatformDetector.readHeaderBytes(zis)); + } + zis.closeEntry(); + } + } + return null; + } + + /** + * Stream a setup .jsdos bundle on-the-fly by reading the main bundle + * and replacing config files to point at the setup executable. + * No data is written to disk — the modified ZIP is streamed directly. + */ + public void streamSetupBundle(String id, OutputStream out) throws IOException { + Game game = load(id); + String setupExe = game.getSetupExe(); + if (setupExe == null || setupExe.isBlank()) { + throw new IOException("No setup executable configured for this game"); + } + + Path bundlePath = gamesDir.resolve(game.getBundleFile()); + if (!Files.exists(bundlePath)) { + throw new NoSuchFileException("Bundle not found: " + game.getBundleFile()); + } + + // Build new config entries — preserve any CD images in the bundle + List cdImages = cdImageService.findInBundle(bundlePath); + byte[] newDosboxConf = config.buildDosboxConfBytes(setupExe, cdImages); + byte[] newJsdosJson = config.buildJsdosJson(setupExe, cdImages); + + try (var zis = new java.util.zip.ZipInputStream(Files.newInputStream(bundlePath)); + var zos = new java.util.zip.ZipOutputStream(out)) { + + java.util.zip.ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + String name = entry.getName(); + zos.putNextEntry(new java.util.zip.ZipEntry(name)); + if (".jsdos/dosbox.conf".equals(name)) { + zos.write(newDosboxConf); + } else if (".jsdos/jsdos.json".equals(name)) { + zos.write(newJsdosJson); + } else { + zis.transferTo(zos); + } + zos.closeEntry(); + zis.closeEntry(); + } + } + } + + public Path getGamesDir() { return gamesDir; } +} diff --git a/src/main/java/org/dostalgia/GameStore.java b/src/main/java/org/dostalgia/GameStore.java new file mode 100644 index 0000000..3a9626e --- /dev/null +++ b/src/main/java/org/dostalgia/GameStore.java @@ -0,0 +1,18 @@ +package org.dostalgia; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; + +/** + * Contract for game metadata persistence. + * Decouples consumers (IgdbService, GameController) from the concrete storage implementation. + */ +public interface GameStore { + Game load(String id) throws IOException; + void save(Game game) throws IOException; + List list() throws IOException; + void delete(String id) throws IOException; + Path gameDir(String id); + Path gameJsonPath(String id); +} diff --git a/src/main/java/org/dostalgia/IgdbController.java b/src/main/java/org/dostalgia/IgdbController.java new file mode 100644 index 0000000..acef056 --- /dev/null +++ b/src/main/java/org/dostalgia/IgdbController.java @@ -0,0 +1,84 @@ +package org.dostalgia; + +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DefaultValue; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.util.Map; + +/** IGDB metadata & artwork integration. Enabled when TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET are set. */ +@Path("/api/igdb") +@Produces(MediaType.APPLICATION_JSON) +public class IgdbController { + + @Inject + IgdbService svc; + + @Inject + GameService games; + + @GET + @Path("/search") + public Response search(@QueryParam("q") @DefaultValue("") String query) { + if (query.isBlank()) { + return Response.status(400).entity(Map.of("error", "Query parameter 'q' is required")).build(); + } + try { + var results = svc.search(query); + return Response.ok(Map.of("results", results)).build(); + } catch (Exception e) { + return Response.serverError() + .entity(Map.of("error", "IGDB search failed: " + e.getMessage())) + .build(); + } + } + + /** + * Apply IGDB metadata + cover to a game. + * Body: {"igdb_id": 12345} — applies data from that specific IGDB game entry. + * Body: {} — auto-search using the game's title. + */ + @POST + @Path("/scrape/{gameId}") + @Consumes(MediaType.APPLICATION_JSON) + public Response scrape(@PathParam("gameId") String gameId, Map body) { + try { + var game = games.load(gameId); + + if (body != null && body.containsKey("igdb_id")) { + int igdbId = ((Number) body.get("igdb_id")).intValue(); + svc.applyIgdbId(game, igdbId); + } else { + svc.autoScrape(game); + } + + games.save(game); + return Response.ok(game).build(); + + } catch (java.nio.file.NoSuchFileException e) { + return Response.status(404).entity(Map.of("error", "Game not found")).build(); + } catch (Exception e) { + return Response.serverError() + .entity(Map.of("error", "IGDB scrape failed: " + e.getMessage())) + .build(); + } + } + + /** Check if IGDB credentials are configured. */ + @GET + @Path("/status") + public Response status() { + boolean configured = svc.isConfigured(); + return Response.ok(Map.of( + "configured", configured, + "message", configured ? "IGDB ready" : "Set TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET env vars" + )).build(); + } +} diff --git a/src/main/java/org/dostalgia/IgdbService.java b/src/main/java/org/dostalgia/IgdbService.java new file mode 100644 index 0000000..6d55e7e --- /dev/null +++ b/src/main/java/org/dostalgia/IgdbService.java @@ -0,0 +1,352 @@ +package org.dostalgia; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Calendar; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.logging.Logger; + +/** Client for the IGDB (Internet Game Database) API via Twitch OAuth2. */ +@ApplicationScoped +public class IgdbService { + + private static final Logger LOG = Logger.getLogger(IgdbService.class.getName()); + private static final String TWITCH_AUTH = "https://id.twitch.tv/oauth2/token"; + private static final String IGDB_API = "https://api.igdb.com/v4"; + private static final String IMG_BASE = "https://images.igdb.com/igdb/image/upload"; + private static final String COVER_SIZE = "t_cover_big"; + + private final HttpClient http = HttpClient.newHttpClient(); + private final ObjectMapper mapper = new ObjectMapper(); + + @ConfigProperty(name = "TWITCH_CLIENT_ID") + Optional clientId; + + @ConfigProperty(name = "TWITCH_CLIENT_SECRET") + Optional clientSecret; + + @Inject + GameStore gameStore; + + private String accessToken; + private Instant tokenExpiry; + + public boolean isConfigured() { + return clientId.isPresent() && clientSecret.isPresent() + && !clientId.get().isBlank() && !clientSecret.get().isBlank(); + } + + private synchronized String getToken() throws Exception { + if (accessToken != null && tokenExpiry != null && Instant.now().isBefore(tokenExpiry)) { + return accessToken; + } + String cid = clientId.orElseThrow(() -> new RuntimeException("TWITCH_CLIENT_ID not configured")); + String secret = clientSecret.orElseThrow(() -> new RuntimeException("TWITCH_CLIENT_SECRET not configured")); + String body = "client_id=" + cid + + "&client_secret=" + secret + + "&grant_type=client_credentials"; + + HttpRequest req = HttpRequest.newBuilder() + .uri(URI.create(TWITCH_AUTH)) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + + HttpResponse res = http.send(req, HttpResponse.BodyHandlers.ofString()); + if (res.statusCode() != 200) { + throw new RuntimeException("Twitch OAuth2 failed: HTTP " + res.statusCode() + " " + res.body()); + } + + JsonNode json = mapper.readTree(res.body()); + accessToken = json.get("access_token").asText(); + int expiresIn = json.get("expires_in").asInt(); + tokenExpiry = Instant.now().plusSeconds(expiresIn - 120); + LOG.info("IGDB: acquired new Twitch token (expires in " + expiresIn + "s)"); + return accessToken; + } + + private HttpRequest.Builder igdbRequest(String path) throws Exception { + String cid = clientId.orElseThrow(() -> new RuntimeException("TWITCH_CLIENT_ID not configured")); + return HttpRequest.newBuilder() + .uri(URI.create(IGDB_API + path)) + .header("Client-ID", cid) + .header("Authorization", "Bearer " + getToken()) + .header("Content-Type", "text/plain"); + } + + /** POST an APQL query to an IGDB endpoint and return the JSON array, or null on failure. */ + private JsonNode postAndGet(String endpoint, String apql) throws Exception { + HttpRequest req = igdbRequest(endpoint) + .POST(HttpRequest.BodyPublishers.ofString(apql)) + .build(); + HttpResponse res = http.send(req, HttpResponse.BodyHandlers.ofString()); + if (res.statusCode() != 200) return null; + JsonNode results = mapper.readTree(res.body()); + return results.isArray() ? results : null; + } + + public List> search(String query) throws Exception { + if (!isConfigured()) return List.of(); + + String apql = "search \"" + sanitize(query) + "\";" + + " fields name,first_release_date,genres.name," + + " involved_companies.company.name,involved_companies.developer,involved_companies.publisher," + + " summary,cover.url,platforms;" + + " where platforms = [13];" + + " limit 20;"; + + JsonNode results = postAndGet("/games", apql); + if (results == null || results.isEmpty()) { + LOG.info("IGDB search: no results for '" + query + "'"); + return List.of(); + } + + List> out = new ArrayList<>(); + for (JsonNode game : results) { + Map entry = new LinkedHashMap<>(); + entry.put("igdb_id", game.get("id").asInt()); + entry.put("name", game.has("name") ? game.get("name").asText() : query); + + epochToYear(game).ifPresent(y -> entry.put("year", y)); + + List genres = extractGenres(game); + if (!genres.isEmpty()) { + entry.put("genres", genres); + } + + if (game.has("involved_companies") && game.get("involved_companies").isArray()) { + String developer = null; + String publisher = null; + for (JsonNode ic : game.get("involved_companies")) { + if (ic.has("company") && ic.get("company").has("name")) { + String companyName = ic.get("company").get("name").asText(); + boolean isDev = ic.has("developer") && ic.get("developer").asBoolean(); + boolean isPub = ic.has("publisher") && ic.get("publisher").asBoolean(); + if (isDev) developer = companyName; + if (isPub) publisher = companyName; + } + } + if (developer != null) entry.put("developer", developer); + if (publisher != null) entry.put("publisher", publisher); + } + + if (game.has("summary")) { + entry.put("summary", game.get("summary").asText()); + } + + if (game.has("platforms") && game.get("platforms").isArray()) { + List platformIds = new ArrayList<>(); + for (JsonNode p : game.get("platforms")) { + platformIds.add(p.asInt()); + } + entry.put("platform_ids", platformIds); + entry.put("is_dos", platformIds.contains(13)); + } + + if (game.has("cover") && game.get("cover").has("url")) { + String thumbUrl = game.get("cover").get("url").asText(); + String coverUrl = "https:" + thumbUrl.replace("t_thumb", COVER_SIZE); + entry.put("cover_url", coverUrl); + } else if (game.has("cover") && game.get("cover").isInt()) { + int coverId = game.get("cover").asInt(); + String coverUrl = fetchCoverUrl(coverId); + if (coverUrl != null) entry.put("cover_url", coverUrl); + } + + out.add(entry); + } + return out; + } + + private String fetchCoverUrl(int coverId) throws Exception { + JsonNode results = postAndGet("/covers", "fields url; where id = " + coverId + ";"); + if (results == null || results.isEmpty()) return null; + String thumbUrl = results.get(0).get("url").asText(); + return IMG_BASE + "/" + COVER_SIZE + "/" + thumbUrl.substring(thumbUrl.lastIndexOf('/') + 1); + } + + private List fetchVideos(int igdbId) throws Exception { + JsonNode results = postAndGet("/game_videos", "fields video_id; where game = " + igdbId + ";"); + if (results == null) return List.of(); + List videos = new ArrayList<>(); + for (JsonNode v : results) { + if (v.has("video_id")) videos.add(v.get("video_id").asText()); + } + return videos; + } + + private List fetchScreenshots(int igdbId) throws Exception { + JsonNode results = postAndGet("/screenshots", "fields url; where game = " + igdbId + ";"); + if (results == null) return List.of(); + List screenshots = new ArrayList<>(); + for (JsonNode s : results) { + if (s.has("url")) { + String fullUrl = "https:" + s.get("url").asText().replace("t_thumb", "t_1080p"); + screenshots.add(fullUrl); + } + } + return screenshots; + } + + public void autoScrape(Game game) { + if (!isConfigured()) return; + try { + List> results = search(game.getTitle()); + if (results.isEmpty()) { + LOG.info("IGDB auto-scrape: no results for '" + game.getTitle() + "'"); + return; + } + Map best = null; + for (Map r : results) { + if (Boolean.TRUE.equals(r.get("is_dos"))) { best = r; break; } + } + if (best == null) best = results.getFirst(); + applyMatch(game, best); + LOG.info("IGDB auto-scrape: applied '" + best.get("name") + "' to '" + game.getTitle() + "'"); + Object igdbIdObj = best.get("igdb_id"); + if (igdbIdObj instanceof Number igdbIdNum) { + int igdbId = igdbIdNum.intValue(); + try { List v = fetchVideos(igdbId); if (!v.isEmpty()) game.setVideos(v); } + catch (Exception e) { LOG.warning("IGDB: failed to fetch videos: " + e.getMessage()); } + try { List s = fetchScreenshots(igdbId); if (!s.isEmpty()) { game.setScreenshots(s); game.setHasScreenshots(true); } } + catch (Exception e) { LOG.warning("IGDB: failed to fetch screenshots: " + e.getMessage()); } + } + } catch (Exception e) { + LOG.warning("IGDB auto-scrape failed for '" + game.getTitle() + "': " + e.getMessage()); + } + } + + public void applyIgdbId(Game game, int igdbId) throws Exception { + String apql = "fields name,first_release_date,genres.name," + + " involved_companies.company.name,involved_companies.developer,involved_companies.publisher," + + " summary,cover.url;" + + " where id = " + igdbId + ";"; + JsonNode results = postAndGet("/games", apql); + if (results == null || results.isEmpty()) { + throw new RuntimeException("IGDB: no game found with ID " + igdbId); + } + applyMatch(game, results.get(0)); + try { List v = fetchVideos(igdbId); if (!v.isEmpty()) game.setVideos(v); } + catch (Exception e) { LOG.warning("IGDB: failed to fetch videos for game " + igdbId + ": " + e.getMessage()); } + try { List s = fetchScreenshots(igdbId); if (!s.isEmpty()) { game.setScreenshots(s); game.setHasScreenshots(true); } } + catch (Exception e) { LOG.warning("IGDB: failed to fetch screenshots for game " + igdbId + ": " + e.getMessage()); } + } + + @SuppressWarnings("unchecked") + private void applyMatch(Game game, Object matchData) throws Exception { + Map data; + if (matchData instanceof Map) { + data = (Map) matchData; + } else if (matchData instanceof JsonNode node) { + data = jsonNodeToMap(node); + } else { + return; + } + if (data.containsKey("name") && (game.getTitle() == null || game.getTitle().isBlank())) + game.setTitle((String) data.get("name")); + if (data.containsKey("year")) game.setYear((Integer) data.get("year")); + if (data.containsKey("developer")) game.setDeveloper((String) data.get("developer")); + if (data.containsKey("publisher")) game.setPublisher((String) data.get("publisher")); + if (data.containsKey("summary")) game.setDescription((String) data.get("summary")); + if (data.containsKey("genres")) { + List genres = (List) data.get("genres"); + if (!genres.isEmpty()) game.setGenre(genres.getFirst()); + } + if (data.containsKey("igdb_id")) game.setIgdbId((Integer) data.get("igdb_id")); + String coverUrl = (String) data.get("cover_url"); + if (coverUrl != null && !coverUrl.isBlank()) downloadCover(game, coverUrl); + } + + private void downloadCover(Game game, String coverUrl) throws Exception { + String url = coverUrl.startsWith("http") ? coverUrl : "https:" + coverUrl; + HttpRequest req = HttpRequest.newBuilder().uri(URI.create(url)).GET().build(); + HttpResponse res = http.send(req, HttpResponse.BodyHandlers.ofFile( + gameStore.gameDir(game.getId()).resolve("cover.jpg"))); + if (res.statusCode() == 200) { + game.setHasCover(true); + LOG.info("IGDB: cover downloaded for '" + game.getTitle() + "'"); + } else { + LOG.warning("IGDB: cover download failed with HTTP " + res.statusCode()); + } + } + + /** Convert IGDB epoch (seconds) to year, from 'first_release_date' field. */ + private static Optional epochToYear(JsonNode node) { + if (node.has("first_release_date")) { + long epoch = node.get("first_release_date").asLong(); + Calendar cal = Calendar.getInstance(); + cal.setTimeInMillis(epoch * 1000); + return Optional.of(cal.get(Calendar.YEAR)); + } + return Optional.empty(); + } + + /** Extract genre names from a JsonNode that may have a 'genres' array. */ + private static List extractGenres(JsonNode node) { + List genres = new ArrayList<>(); + if (node.has("genres") && node.get("genres").isArray()) { + for (JsonNode g : node.get("genres")) { + if (g.has("name")) genres.add(g.get("name").asText()); + } + } + return genres; + } + + private String sanitize(String s) { + return s.replace("\\", "\\\\").replace("\"", "\\\""); + } + + private Map jsonNodeToMap(JsonNode node) { + Map map = new LinkedHashMap<>(); + map.put("igdb_id", node.has("id") ? node.get("id").asInt() : 0); + map.put("name", node.has("name") ? node.get("name").asText() : ""); + epochToYear(node).ifPresent(y -> map.put("year", y)); + + List genres = extractGenres(node); + if (!genres.isEmpty()) { + map.put("genres", genres); + } + if (node.has("involved_companies") && node.get("involved_companies").isArray()) { + for (JsonNode ic : node.get("involved_companies")) { + if (ic.has("company") && ic.get("company").has("name")) { + String name = ic.get("company").get("name").asText(); + boolean isDev = ic.has("developer") && ic.get("developer").asBoolean(); + boolean isPub = ic.has("publisher") && ic.get("publisher").asBoolean(); + if (isDev && !map.containsKey("developer")) map.put("developer", name); + if (isPub && !map.containsKey("publisher")) map.put("publisher", name); + } + } + } + if (node.has("summary")) map.put("summary", node.get("summary").asText()); + if (node.has("cover")) { + JsonNode cover = node.get("cover"); + try { + if (cover.has("url")) { + String coverUrl = "https:" + cover.get("url").asText().replace("t_thumb", COVER_SIZE); + map.put("cover_url", coverUrl); + } else if (cover.isInt() || cover.isLong()) { + String coverUrl = fetchCoverUrl(cover.asInt()); + if (coverUrl != null) map.put("cover_url", coverUrl); + } else if (cover.has("id")) { + String coverUrl = fetchCoverUrl(cover.get("id").asInt()); + if (coverUrl != null) map.put("cover_url", coverUrl); + } + } catch (Exception ignored) {} + } + return map; + } +} diff --git a/src/main/java/org/dostalgia/PlatformDetector.java b/src/main/java/org/dostalgia/PlatformDetector.java new file mode 100644 index 0000000..4366dc6 --- /dev/null +++ b/src/main/java/org/dostalgia/PlatformDetector.java @@ -0,0 +1,65 @@ +package org.dostalgia; + +import jakarta.enterprise.context.ApplicationScoped; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.logging.Logger; + +/** + * Detects platform type (DOS vs Windows) by reading executable headers. + */ +@ApplicationScoped +public class PlatformDetector { + + private static final Logger LOG = Logger.getLogger(PlatformDetector.class.getName()); + + public String detect(Path exePath) { + String name = exePath.getFileName().toString().toLowerCase(); + if (name.endsWith(".com") || name.endsWith(".bat")) return "dos"; + + try (var fis = Files.newInputStream(exePath)) { + return detectFromBytes(readHeaderBytes(fis)); + } catch (IOException e) { + LOG.warning("Could not read EXE header for " + exePath + ": " + e.getMessage()); + return null; + } + } + + /** Read enough PE header bytes from any InputStream (shared by Path and ZIP callers). */ + static byte[] readHeaderBytes(InputStream is) throws IOException { + byte[] buf = new byte[0x1000]; + int read = is.readNBytes(buf, 0, buf.length); + return read < buf.length ? java.util.Arrays.copyOf(buf, read) : buf; + } + + /** Detect platform from pre-read MZ/PE header bytes. */ + static String detectFromBytes(byte[] buf) { + return detectFromBytes(buf, buf.length); + } + + /** Detect platform from pre-read MZ/PE header bytes with explicit read length. */ + static String detectFromBytes(byte[] buf, int read) { + if (read < 2) return null; + if (buf[0] != 0x4D || buf[1] != 0x5A) return null; + if (read < 0x40) return "dos"; + + int peOffset = (buf[0x3C] & 0xFF) + | ((buf[0x3D] & 0xFF) << 8) + | ((buf[0x3E] & 0xFF) << 16) + | ((buf[0x3F] & 0xFF) << 24); + + if (peOffset < 0 || peOffset + 2 > read) return "dos"; + + byte sig1 = buf[peOffset]; + byte sig2 = buf[peOffset + 1]; + + if ((sig1 == 0x50 && sig2 == 0x45) // PE + || (sig1 == 0x4E && sig2 == 0x45)) // NE + return "windows"; + + return "dos"; + } +} diff --git a/src/main/java/org/dostalgia/StaticResource.java b/src/main/java/org/dostalgia/StaticResource.java new file mode 100644 index 0000000..93009fa --- /dev/null +++ b/src/main/java/org/dostalgia/StaticResource.java @@ -0,0 +1,78 @@ +package org.dostalgia; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.StreamingOutput; +import org.eclipse.microprofile.config.inject.ConfigProperty; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; + +/** Serves game bundles (.jsdos) and artwork from the external data directory. */ +@jakarta.ws.rs.Path("/") +public class StaticResource { + + @ConfigProperty(name = "dostalgia.data.dir", defaultValue = "data") + String dataDir; + + /** Serve a file from a subdirectory of the data dir with path traversal protection. */ + private Response serveFile(Path base, String path, String cacheHeader) { + Path file = base.resolve(path).normalize(); + if (!file.startsWith(base)) { + return Response.status(403).build(); + } + if (!Files.exists(file) || Files.isDirectory(file)) { + return Response.status(404).build(); + } + String contentType = guessContentType(file.getFileName().toString()); + var builder = Response.ok(new FileStream(file)).type(contentType); + if (cacheHeader != null) { + builder.header(cacheHeader.split(":")[0], cacheHeader.split(":", 2)[1].trim()); + } + return builder.build(); + } + + @GET + @jakarta.ws.rs.Path("/games/{path: .*}") + public Response getGameFile(@PathParam("path") String path) { + return serveFile(Path.of(dataDir, "games"), path, "Accept-Ranges: bytes"); + } + + @GET + @jakarta.ws.rs.Path("/artwork/{path: .*}") + public Response getArtwork(@PathParam("path") String path) { + return serveFile(Path.of(dataDir, "artwork"), path, "Cache-Control: public, max-age=86400"); + } + + /** Health check */ + @GET + @jakarta.ws.rs.Path("/api/health") + public Response health() { + return Response.ok(Map.of("status", "ok", "version", "0.1.0")).build(); + } + + private String guessContentType(String name) { + String n = name.toLowerCase(); + if (n.endsWith(".jsdos")) return "application/zip"; + if (n.endsWith(".zip")) return "application/zip"; + if (n.endsWith(".jpg") || n.endsWith(".jpeg")) return "image/jpeg"; + if (n.endsWith(".png")) return "image/png"; + if (n.endsWith(".webp")) return "image/webp"; + if (n.endsWith(".gif")) return "image/gif"; + if (n.endsWith(".json")) return "application/json"; + return MediaType.APPLICATION_OCTET_STREAM; + } + + /** Streaming file output that avoids loading the whole file into memory. */ + private record FileStream(Path file) implements StreamingOutput { + @Override + public void write(OutputStream output) throws IOException { + Files.copy(file, output); + } + } +} diff --git a/src/main/java/org/dostalgia/UploadResource.java b/src/main/java/org/dostalgia/UploadResource.java new file mode 100644 index 0000000..a41dbac --- /dev/null +++ b/src/main/java/org/dostalgia/UploadResource.java @@ -0,0 +1,222 @@ +package org.dostalgia; + +import jakarta.inject.Inject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.jboss.resteasy.reactive.RestForm; +import org.jboss.resteasy.reactive.multipart.FileUpload; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.List; +import java.util.Map; +import java.util.logging.Logger; + +@jakarta.ws.rs.Path("/api/upload") +@Produces(MediaType.APPLICATION_JSON) +public class UploadResource { + + @Inject + GameService svc; + + @Inject + IgdbService igdb; + + @Inject + ZipService zip; + + @Inject + ExecutableDetector exeDetector; + + @Inject + PlatformDetector platform; + + @Inject + CdImageService cdImageService; + + @Inject + ConfigPatcher configPatcher; + + private static final Logger LOG = Logger.getLogger(UploadResource.class.getName()); + + @POST + @Consumes(MediaType.MULTIPART_FORM_DATA) + public Response upload( + @RestForm("file") FileUpload upload, + @RestForm("title") String title, + @RestForm("igdb_id") Integer igdbId + ) throws Exception { + if (upload == null) { + return Response.status(400).entity(Map.of("error", "No file provided")).build(); + } + + String filename = upload.fileName(); + if (title == null || title.isBlank()) { + title = filename.endsWith(".zip") ? filename.substring(0, filename.length() - 4) : filename; + } + + String gameId = GameService.sanitizeId(title); + + // Check for duplicate by title (case-insensitive) + for (var g : svc.list()) { + if (g.getTitle() != null && g.getTitle().equalsIgnoreCase(title)) { + return Response.status(409) + .entity(Map.of("error", "\"" + title + "\" is already in your library")) + .build(); + } + } + + // Ensure unique ID + String baseId = gameId; + int counter = 2; + while (Files.exists(svc.gameJsonPath(gameId))) { + gameId = baseId + "-" + counter++; + } + + // Extract ZIP + Path tmpDir = Files.createTempDirectory("dostalgia-"); + try { + Path zipPath = tmpDir.resolve(filename); + Files.copy(upload.uploadedFile(), zipPath, StandardCopyOption.REPLACE_EXISTING); + + Path extractDir = tmpDir.resolve("extracted"); + zip.unzip(zipPath, extractDir); + // If the ZIP has a single root directory, flatten it + try { + zip.flattenSingleDir(extractDir); + } catch (Exception e) { + LOG.warning("Flatten failed for '" + filename + "': " + e.getMessage() + + " — continuing (cd in autoexec handles subdirs)"); + } + + // Fix .cue files that reference non-existent data files (e.g. CloneCD) + try { + cdImageService.fixCueReferences(extractDir); + } catch (Exception e) { + LOG.warning("Failed to fix cue references: " + e.getMessage()); + } + + // Patch game config files with hardcoded absolute paths + try { + configPatcher.fixAbsolutePaths(extractDir); + } catch (Exception e) { + LOG.warning("Failed to patch config paths: " + e.getMessage()); + } + + // Find main executable + String mainExe = exeDetector.findMain(extractDir); + + // Detect CD images + List cdImages = cdImageService.findInDirectory(extractDir); + + if (mainExe == null) { + if (cdImages.isEmpty()) { + return Response.status(400) + .entity(Map.of("error", "No executable (.exe/.com/.bat) found in the archive")) + .build(); + } + // CD-only game — proceed without executable + } + + // Find setup executable + String setupExe = exeDetector.findSetup(extractDir); + + // Create game directory and .jsdos bundle + Path gameDir = svc.gameDir(gameId); + Files.createDirectories(gameDir); + + String bundleFile = gameId + "/" + gameId + ".jsdos"; + Path bundlePath = gameDir.resolve(gameId + ".jsdos"); + zip.createBundle(extractDir, mainExe, cdImages, bundlePath); + + // Detect platform (DOS vs Windows) + Path mainExePath = mainExe != null ? Path.of(mainExe) : null; + String platformType = mainExe != null ? platform.detect(mainExePath) : "dos"; + + // Save metadata + Game game = new Game(gameId, title); + game.setBundleFile(bundleFile); + game.setPlatform(platformType); + + // Store the selected executable and the full list for the UI picker + String relMain = mainExe != null + ? extractDir.relativize(mainExePath).toString().replace('\\', '/') + : ""; + game.setExecutable(relMain); + game.setExecutables(exeDetector.findAll(extractDir)); + if (setupExe != null) { + Path setupExePath = Path.of(setupExe); + String setupPlatform = platform.detect(setupExePath); + if (!"windows".equals(setupPlatform)) { + game.setSetupExe(extractDir.relativize(setupExePath).toString()); + } + } + game.setReady(true); + svc.save(game); + + // Auto-populate from IGDB (use provided igdb_id or auto-search) + if (igdb.isConfigured()) { + if (igdbId != null) { + igdb.applyIgdbId(game, igdbId); + } else { + igdb.autoScrape(game); + } + if (game.isHasCover() || game.getYear() != null || game.getGenre() != null + || (game.getVideos() != null && !game.getVideos().isEmpty()) + || (game.getScreenshots() != null && !game.getScreenshots().isEmpty())) { + svc.save(game); + } + } + + return Response.status(201).entity(game).build(); + + } catch (Exception e) { + LOG.severe("Upload failed for '" + filename + "': " + e.getMessage()); + return Response.serverError() + .entity(Map.of("error", "Upload failed: " + e.getMessage())) + .build(); + } finally { + deleteDir(tmpDir); + } + } + + @POST + @jakarta.ws.rs.Path("/{id}/cover") + @Consumes(MediaType.MULTIPART_FORM_DATA) + public Response uploadCover(@PathParam("id") String id, @RestForm("file") FileUpload upload) throws Exception { + if (upload == null) { + return Response.status(400).entity(Map.of("error", "No file provided")).build(); + } + if (!Files.exists(svc.gameJsonPath(id))) { + return Response.status(404).entity(Map.of("error", "Game not found")).build(); + } + + String fname = upload.fileName().toLowerCase(); + String ext; + if (fname.endsWith(".jpg") || fname.endsWith(".jpeg")) ext = ".jpg"; + else if (fname.endsWith(".png")) ext = ".png"; + else if (fname.endsWith(".webp")) ext = ".webp"; + else { + return Response.status(400).entity(Map.of("error", "Only JPG, PNG, or WebP images accepted")).build(); + } + + Path gameDir = svc.gameDir(id); + // Remove old covers + for (String old : new String[]{".jpg", ".jpeg", ".png", ".webp"}) { + Files.deleteIfExists(gameDir.resolve("cover" + old)); + } + + Files.copy(upload.uploadedFile(), gameDir.resolve("cover" + ext), StandardCopyOption.REPLACE_EXISTING); + return Response.ok(Map.of("status", "ok")).build(); + } + + private void deleteDir(Path dir) throws IOException { + FileUtils.deleteDirectory(dir); + } +} diff --git a/src/main/java/org/dostalgia/ZipService.java b/src/main/java/org/dostalgia/ZipService.java new file mode 100644 index 0000000..5cf6ded --- /dev/null +++ b/src/main/java/org/dostalgia/ZipService.java @@ -0,0 +1,161 @@ +package org.dostalgia; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Comparator; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; +import java.util.logging.Logger; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +/** + * ZIP extraction, directory flattening, and .jsdos bundle creation. + */ +@ApplicationScoped +public class ZipService { + + private static final Logger LOG = Logger.getLogger(ZipService.class.getName()); + + @Inject + ConfigBuilder config; + + private static final Set SKIP_EXT = Set.of( + ".nrg", ".mdf", ".mds", ".sub", ".dmg" + ); + + /** Unzip an archive to a destination directory (path traversal safe). */ + public void unzip(Path zipPath, Path dest) throws IOException { + try (var zis = new java.util.zip.ZipInputStream(Files.newInputStream(zipPath))) { + java.util.zip.ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + Path target = dest.resolve(entry.getName()).normalize(); + if (!target.startsWith(dest)) continue; + if (entry.isDirectory()) { + Files.createDirectories(target); + } else { + Files.createDirectories(target.getParent()); + Files.copy(zis, target, StandardCopyOption.REPLACE_EXISTING); + } + zis.closeEntry(); + } + } + } + + /** + * If the extraction directory contains a single subdirectory, move its contents up. + */ + public void flattenSingleDir(Path dir) throws IOException { + Path singleDir = null; + try (var files = Files.list(dir)) { + for (Path entry : files.toList()) { + if (Files.isDirectory(entry)) { + if (singleDir != null) return; + singleDir = entry; + } + } + } + if (singleDir == null) return; + + LOG.info("Flattening root directory: " + singleDir.getFileName()); + try (var walk = Files.walk(singleDir)) { + var list = walk.sorted(Comparator.reverseOrder()).toList(); + for (Path f : list) { + if (f.equals(singleDir)) continue; + Path target = dir.resolve(singleDir.relativize(f)); + if (Files.isDirectory(f)) { + Files.createDirectories(target); + Files.delete(f); + } else { + Files.createDirectories(target.getParent()); + Files.move(f, target, StandardCopyOption.REPLACE_EXISTING); + } + } + } + Files.delete(singleDir); + LOG.info("Flattened " + singleDir.getFileName()); + } + + /** + * Create a .jsdos bundle ZIP from the extracted game directory. + */ + public void createBundle(Path extractDir, String exePath, List cdImages, Path bundlePath) throws IOException { + Path jsdos = extractDir.resolve(".jsdos"); + Files.createDirectories(jsdos); + if (exePath != null) { + String relExe = extractDir.relativize(Path.of(exePath)).toString(); + Files.writeString(jsdos.resolve("dosbox.conf"), config.buildDosboxConf(relExe, cdImages)); + Files.write(jsdos.resolve("jsdos.json"), config.buildJsdosJson(relExe, cdImages)); + } else { + Files.writeString(jsdos.resolve("dosbox.conf"), config.buildCdOnlyDosboxConf(cdImages)); + Files.write(jsdos.resolve("jsdos.json"), config.buildCdOnlyJsdosJson(cdImages)); + } + + try (var zos = new ZipOutputStream(Files.newOutputStream(bundlePath))) { + + // Phase 1: Collect all directory paths + var allDirs = new TreeSet(); + try (var walk = Files.walk(extractDir)) { + walk.filter(Files::isRegularFile).forEach(f -> { + String entryName = extractDir.relativize(f).toString().replace('\\', '/'); + int idx = entryName.lastIndexOf('/'); + while (idx >= 0) { + allDirs.add(entryName.substring(0, idx + 1)); + idx = entryName.lastIndexOf('/', idx - 1); + } + }); + } + + // Phase 2: Write directory entries first + for (String dir : allDirs) { + zos.putNextEntry(new ZipEntry(dir)); + zos.closeEntry(); + } + + // Phase 3: .jsdos config files (before game files) + try (var walk = Files.walk(jsdos)) { + walk.filter(Files::isRegularFile).sorted() + .forEach(f -> zipEntry(zos, extractDir, f)); + } + + // Phase 4: Game files (excluding skipped extensions) + try (var walk = Files.walk(extractDir)) { + walk.filter(Files::isRegularFile) + .filter(f -> !f.startsWith(jsdos)) + .filter(f -> { + String n = f.getFileName().toString().toLowerCase(); + int dot = n.lastIndexOf('.'); + return dot < 0 || !SKIP_EXT.contains(n.substring(dot)); + }) + .sorted() + .forEach(f -> zipEntry(zos, extractDir, f)); + } + } + + // Clean up .jsdos config files from the extract dir + try (var cleanup = Files.walk(jsdos)) { + cleanup.sorted(Comparator.reverseOrder()).forEach(p -> { + try { Files.deleteIfExists(p); } catch (IOException ignored) {} + }); + } + } + + /** Write a single file into a ZIP output stream, computing entry name relative to extractDir. */ + private static void zipEntry(ZipOutputStream zos, Path extractDir, Path file) { + try { + String entryName = extractDir.relativize(file).toString().replace('\\', '/'); + zos.putNextEntry(new ZipEntry(entryName)); + Files.copy(file, zos); + zos.closeEntry(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..b17f240 --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,16 @@ +# ─── Server ────────────────────────────────────── +quarkus.http.port=8765 +quarkus.http.host=0.0.0.0 +quarkus.http.cors=true + +# Allow large game uploads (DOS games can be 500MB+) +quarkus.http.limits.max-body-size=2048M + +# ─── Jackson: snake_case to match frontend expectations ─ +quarkus.jackson.property-naming-strategy=SNAKE_CASE + +# ─── Static resources (frontend SPA from META-INF/resources/) ── +quarkus.http.static-resources.enable=true + +# ─── Data directory (game files, artwork) ──────── +dostalgia.data.dir=/data diff --git a/src/test/java/org/dostalgia/CdImageServiceTest.java b/src/test/java/org/dostalgia/CdImageServiceTest.java new file mode 100644 index 0000000..c872856 --- /dev/null +++ b/src/test/java/org/dostalgia/CdImageServiceTest.java @@ -0,0 +1,106 @@ +package org.dostalgia; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CdImageServiceTest { + + private final CdImageService service = new CdImageService(); + + @Test + void findInDirectory_findsAllCdFormats(@TempDir Path dir) throws Exception { + Files.writeString(dir.resolve("game.cue"), "FILE \"game.bin\" BINARY"); + Files.writeString(dir.resolve("game.bin"), "data"); + Files.writeString(dir.resolve("game.iso"), "iso"); + Files.writeString(dir.resolve("readme.txt"), "text"); + + List result = service.findInDirectory(dir); + assertEquals(3, result.size()); + assertTrue(result.contains("game.bin")); + assertTrue(result.contains("game.cue")); + assertTrue(result.contains("game.iso")); + assertFalse(result.contains("readme.txt")); + } + + @Test + void findInDirectory_emptyDir(@TempDir Path dir) throws Exception { + assertTrue(service.findInDirectory(dir).isEmpty()); + } + + @Test + void findInDirectory_caseInsensitive(@TempDir Path dir) throws Exception { + Files.writeString(dir.resolve("GAME.CUE"), "data"); + Files.writeString(dir.resolve("GAME.ISO"), "data"); + List result = service.findInDirectory(dir); + assertEquals(2, result.size()); + } + + @Test + void fixCueReferences_rewritesNonexistentReference(@TempDir Path dir) throws Exception { + // CUE references game.bin, but only game.img exists + Files.writeString(dir.resolve("game.cue"), "FILE \"game.bin\" BINARY\n TRACK 01 MODE1/2352\n INDEX 01 00:00:00"); + Files.writeString(dir.resolve("game.img"), "data"); + + service.fixCueReferences(dir); + + String fixed = Files.readString(dir.resolve("game.cue")); + assertTrue(fixed.contains("game.img"), "Should reference existing .img file"); + assertFalse(fixed.contains("game.bin"), "Should no longer reference missing .bin"); + } + + @Test + void fixCueReferences_skipsWhenExists(@TempDir Path dir) throws Exception { + // CUE references game.bin — and it exists, so no change + String original = "FILE \"game.bin\" BINARY\n TRACK 01 MODE1/2352\n INDEX 01 00:00:00"; + Files.writeString(dir.resolve("game.cue"), original); + Files.writeString(dir.resolve("game.bin"), "data"); + + service.fixCueReferences(dir); + + assertEquals(original, Files.readString(dir.resolve("game.cue")).trim()); + } + + @Test + void fixCueReferences_noFileLine_noChange(@TempDir Path dir) throws Exception { + String original = "TITLE \"Test Game\"\nTRACK 01 MODE1/2352"; + Files.writeString(dir.resolve("game.cue"), original); + + service.fixCueReferences(dir); + + assertEquals(original, Files.readString(dir.resolve("game.cue")).trim()); + } + + @Test + void findInBundle_findsCdImages(@TempDir Path dir) throws Exception { + // Create a test bundle with CD images and regular files + Path bundle = dir.resolve("test.jsdos"); + try (var zos = new java.util.zip.ZipOutputStream(Files.newOutputStream(bundle))) { + zos.putNextEntry(new java.util.zip.ZipEntry("game.cue")); + zos.write("cue data".getBytes()); + zos.closeEntry(); + zos.putNextEntry(new java.util.zip.ZipEntry("game.iso")); + zos.write("iso data".getBytes()); + zos.closeEntry(); + zos.putNextEntry(new java.util.zip.ZipEntry("readme.txt")); + zos.write("text".getBytes()); + zos.closeEntry(); + zos.putNextEntry(new java.util.zip.ZipEntry("subdir/game.img")); + zos.write("img".getBytes()); + zos.closeEntry(); + } + + List result = service.findInBundle(bundle); + assertEquals(3, result.size()); + assertTrue(result.contains("game.cue")); + assertTrue(result.contains("game.iso")); + assertTrue(result.contains("subdir/game.img")); + } +} diff --git a/src/test/java/org/dostalgia/ConfigBuilderTest.java b/src/test/java/org/dostalgia/ConfigBuilderTest.java new file mode 100644 index 0000000..b8eb51b --- /dev/null +++ b/src/test/java/org/dostalgia/ConfigBuilderTest.java @@ -0,0 +1,80 @@ +package org.dostalgia; + +import org.junit.jupiter.api.Test; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertNull; + +class ConfigBuilderTest { + + private final ConfigBuilder builder = new ConfigBuilder(); + + @Test + void buildDosboxConf_noCdImages() { + String conf = builder.buildDosboxConf("FALLOUT.EXE", List.of()); + assertTrue(conf.contains("memsize=64"), "Should include memsize"); + assertTrue(conf.contains("mount c ."), "Should mount C drive"); + assertTrue(conf.contains("FALLOUT.EXE"), "Should reference executable"); + assertFalse(conf.contains("imgmount"), "No CD images should produce no imgmount"); + } + + @Test + void buildDosboxConf_withCdImages() { + String conf = builder.buildDosboxConf("DOTT.EXE", List.of("game.cue", "game.bin")); + assertTrue(conf.contains("imgmount D \"game.bin\" -t cdrom -fs iso"), "Should mount .bin with iso flag"); + assertFalse(conf.contains("game.cue"), "Should skip .cue when .bin exists"); + } + + @Test + void buildDosboxConf_subdirectory() { + String conf = builder.buildDosboxConf("FALLOUT/FALLOUT.EXE", List.of()); + assertTrue(conf.contains("cd FALLOUT"), "Should cd into subdirectory"); + assertTrue(conf.contains("path=c:\\"), "Should set path"); + } + + @Test + void buildCdOnlyDosboxConf() { + String conf = builder.buildCdOnlyDosboxConf(List.of("game.iso")); + assertTrue(conf.contains("runs from the CD-ROM"), "CD-only message"); + assertTrue(conf.contains("imgmount D"), "Should mount CD"); + } + + @Test + void buildJsdosJson() { + byte[] json = builder.buildJsdosJson("GAME.EXE", List.of()); + String s = new String(json); + assertTrue(s.contains("\"script\""), "Should have script key"); + assertTrue(s.contains("\"autolock\""), "Should have autolock"); + assertTrue(s.contains("GAME.EXE"), "Should contain executable name"); + } + + @Test + void splitExePath_rootLevel() { + var parts = ConfigBuilder.splitExePath("GAME.EXE"); + assertNull(parts.dir()); + assertEquals("GAME.EXE", parts.exe()); + } + + @Test + void splitExePath_subdirectory() { + var parts = ConfigBuilder.splitExePath("FALLOUT1/FALLOUT.EXE"); + assertEquals("FALLOUT1", parts.dir()); + assertEquals("FALLOUT.EXE", parts.exe()); + } + + @Test + void splitExePath_nullOrEmpty() { + assertNull(ConfigBuilder.splitExePath(null).dir()); + assertEquals("", ConfigBuilder.splitExePath("").exe()); + } + + @Test + void escapeJson() { + assertEquals("hello", ConfigBuilder.escapeJson("hello")); + assertEquals("\\\\n", ConfigBuilder.escapeJson("\\n")); + assertEquals("he said \\\"hi\\\"", ConfigBuilder.escapeJson("he said \"hi\"")); + } +} diff --git a/src/test/java/org/dostalgia/ConfigPatcherTest.java b/src/test/java/org/dostalgia/ConfigPatcherTest.java new file mode 100644 index 0000000..21ee8b0 --- /dev/null +++ b/src/test/java/org/dostalgia/ConfigPatcherTest.java @@ -0,0 +1,114 @@ +package org.dostalgia; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ConfigPatcherTest { + + private final ConfigPatcher patcher = new ConfigPatcher(); + + @Test + void fixAbsolutePaths_rewritesStalePrefix(@TempDir Path dir) throws Exception { + // Create a file that references C:\FALLOUT1\MASTER.DAT + // but after flattening, MASTER.DAT is at the root + String content = "master_dat=C:\\FALLOUT1\\MASTER.DAT\ncritter_dat=C:\\FALLOUT1\\CRITTER.DAT\n"; + Files.writeString(dir.resolve("FALLOUT.CFG"), content, StandardCharsets.ISO_8859_1); + + // The referenced files exist at root + Files.writeString(dir.resolve("MASTER.DAT"), "data"); + Files.writeString(dir.resolve("CRITTER.DAT"), "data"); + + patcher.fixAbsolutePaths(dir); + + String fixed = Files.readString(dir.resolve("FALLOUT.CFG"), StandardCharsets.ISO_8859_1); + assertTrue(fixed.contains(".\\MASTER.DAT"), "Should rewrite to relative path"); + assertTrue(fixed.contains(".\\CRITTER.DAT"), "Both files should be rewritten"); + assertFalse(fixed.contains("FALLOUT1"), "Stale prefix should be removed"); + } + + @Test + void fixAbsolutePaths_leavesNonCDrives(@TempDir Path dir) throws Exception { + // D: drive references are CD-ROM — should stay + String content = "cd_path=D:\\GAMEDATA\\SOUND.DAT\n"; + Files.writeString(dir.resolve("config.ini"), content, StandardCharsets.ISO_8859_1); + + patcher.fixAbsolutePaths(dir); + + String fixed = Files.readString(dir.resolve("config.ini"), StandardCharsets.ISO_8859_1); + assertEquals(content.trim(), fixed.trim(), "D: references should be preserved"); + } + + @Test + void fixAbsolutePaths_leavesValidPaths(@TempDir Path dir) throws Exception { + // Path exists with full structure — should stay as-is + Path sub = Files.createDirectory(dir.resolve("VALIDDIR")); + Files.writeString(sub.resolve("file.dat"), "data"); + + String content = "data=C:\\VALIDDIR\\file.dat\n"; + Files.writeString(dir.resolve("config.ini"), content, StandardCharsets.ISO_8859_1); + + patcher.fixAbsolutePaths(dir); + + String fixed = Files.readString(dir.resolve("config.ini"), StandardCharsets.ISO_8859_1); + assertEquals(content.trim(), fixed.trim(), "Valid full paths should be preserved"); + } + + @Test + void fixAbsolutePaths_skipsNonTextFiles(@TempDir Path dir) throws Exception { + // Binary file with null bytes — should not be processed + byte[] binary = new byte[200]; + binary[0] = 'C'; + binary[1] = ':'; + binary[2] = '\\'; + binary[3] = 'D'; + binary[10] = 0; // null byte makes it "not text" + Files.write(dir.resolve("binary.dat"), binary); + + // Should not throw or modify binary files + assertDoesNotThrow(() -> patcher.fixAbsolutePaths(dir)); + + byte[] result = Files.readAllBytes(dir.resolve("binary.dat")); + assertArrayEquals(binary, result, "Binary file should be unchanged"); + } + + @Test + void fixAbsolutePaths_skipsLargeFiles(@TempDir Path dir) throws Exception { + // File > 100KB should be skipped + byte[] large = new byte[101 * 1024]; + large[0] = 'C'; + large[1] = ':'; + large[2] = '\\'; + Files.write(dir.resolve("large.cfg"), large); + + // Should not throw + assertDoesNotThrow(() -> patcher.fixAbsolutePaths(dir)); + } + + @Test + void fixAbsolutePaths_emptyDir_noError(@TempDir Path dir) { + assertDoesNotThrow(() -> patcher.fixAbsolutePaths(dir)); + } + + @Test + void fixAbsolutePaths_unixStylePath(@TempDir Path dir) throws Exception { + // Some games use forward slashes: C:/GAME/DATA + String content = "path=C:/MYGAME/DATA.DAT\n"; + Files.writeString(dir.resolve("config.cfg"), content, StandardCharsets.ISO_8859_1); + Files.writeString(dir.resolve("DATA.DAT"), "data"); + + patcher.fixAbsolutePaths(dir); + + String fixed = Files.readString(dir.resolve("config.cfg"), StandardCharsets.ISO_8859_1); + assertTrue(fixed.contains("./DATA.DAT"), "Forward-slash paths should be fixed"); + } +} diff --git a/src/test/java/org/dostalgia/ExecutableDetectorTest.java b/src/test/java/org/dostalgia/ExecutableDetectorTest.java new file mode 100644 index 0000000..03bd1d5 --- /dev/null +++ b/src/test/java/org/dostalgia/ExecutableDetectorTest.java @@ -0,0 +1,209 @@ +package org.dostalgia; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ExecutableDetectorTest { + + private final PlatformDetector platform = new PlatformDetector(); + private final ExecutableDetector detector = new ExecutableDetector(); + + // Manually wire dependencies since we're not in a CDI container + { + detector.platform = platform; + } + + @Test + void findAll_findsExeComBat(@TempDir Path dir) throws Exception { + Files.writeString(dir.resolve("GAME.EXE"), "MZ"); + Files.writeString(dir.resolve("helper.com"), "MZ"); + Files.writeString(dir.resolve("run.bat"), "@echo off"); + Files.writeString(dir.resolve("readme.txt"), "text"); // should NOT be found + + List result = detector.findAll(dir); + assertEquals(3, result.size()); + assertTrue(result.contains("GAME.EXE")); + assertTrue(result.contains("helper.com")); + assertTrue(result.contains("run.bat")); + assertFalse(result.contains("readme.txt")); + } + + @Test + void findMain_prefersLargestNonInstaller(@TempDir Path dir) throws Exception { + // Small installer + Files.write(dir.resolve("INSTALL.EXE"), createMZ(100)); + // Large real game + Files.write(dir.resolve("GAME.EXE"), createMZ(5000)); + + String main = detector.findMain(dir); + assertEquals(dir.resolve("GAME.EXE").toString(), main); + } + + @Test + void findMain_skipsExtenders(@TempDir Path dir) throws Exception { + Files.write(dir.resolve("DOS4GW.EXE"), createMZ(200)); + Files.write(dir.resolve("GAME.EXE"), createMZ(1000)); + + String main = detector.findMain(dir); + assertEquals(dir.resolve("GAME.EXE").toString(), main); + } + + @Test + void findMain_skipsSelfExtractors(@TempDir Path dir) throws Exception { + byte[] sfx = createMZ(500); + // Embed PKSFX string + String content = new String(sfx); + int pos = content.indexOf("MZ") + 2; + byte[] withSfx = new byte[sfx.length + 20]; + System.arraycopy(sfx, 0, withSfx, 0, sfx.length); + byte[] pksfx = "PKSFX".getBytes(); + System.arraycopy(pksfx, 0, withSfx, pos, pksfx.length); + Files.write(dir.resolve("GAME.EXE"), withSfx); + Files.write(dir.resolve("REAL.EXE"), createMZ(2000)); + + String main = detector.findMain(dir); + assertEquals(dir.resolve("REAL.EXE").toString(), main); + } + + @Test + void findMain_emptyDir_returnsNull(@TempDir Path dir) throws Exception { + assertNull(detector.findMain(dir)); + } + + @Test + void findMain_subdirectoryDeprioritized(@TempDir Path dir) throws Exception { + // Root-level smaller exe + Files.write(dir.resolve("GAME.EXE"), createMZ(1000)); + // Subdirectory larger exe — wins on size before depth is considered + Path sub = Files.createDirectory(dir.resolve("SUBDIR")); + Files.write(sub.resolve("OTHER.EXE"), createMZ(2000)); + + String main = detector.findMain(dir); + // Larger file wins even though it's deeper (size sorts before depth) + assertEquals(sub.resolve("OTHER.EXE").toString(), main); + } + + @Test + void findSetup_findsSetupExe(@TempDir Path dir) throws Exception { + Files.writeString(dir.resolve("SETUP.EXE"), "MZ"); + Files.writeString(dir.resolve("GAME.EXE"), "MZ"); + + String setup = detector.findSetup(dir); + assertEquals(dir.resolve("SETUP.EXE").toString(), setup); + } + + @Test + void findSetup_findsInstallBat(@TempDir Path dir) throws Exception { + Files.writeString(dir.resolve("INSTALL.BAT"), "@echo off"); + + String setup = detector.findSetup(dir); + assertEquals(dir.resolve("INSTALL.BAT").toString(), setup); + } + + @Test + void findSetup_noSetup_returnsNull(@TempDir Path dir) throws Exception { + Files.writeString(dir.resolve("GAME.EXE"), "MZ"); + assertNull(detector.findSetup(dir)); + } + + @Test + void isLikelySelfExtractor_detectsPKSFX(@TempDir Path dir) throws Exception { + byte[] buf = new byte[1000]; + buf[0] = 0x4D; // M + buf[1] = 0x5A; // Z + byte[] pksfx = "PKSFX".getBytes(); + System.arraycopy(pksfx, 0, buf, 10, pksfx.length); + + Path f = dir.resolve("sfx.exe"); + Files.write(f, buf); + assertTrue(ExecutableDetector.isLikelySelfExtractor(f)); + } + + @Test + void isLikelySelfExtractor_plainExe_returnsFalse(@TempDir Path dir) throws Exception { + byte[] buf = createMZ(500); + Path f = dir.resolve("game.exe"); + Files.write(f, buf); + assertFalse(ExecutableDetector.isLikelySelfExtractor(f)); + } + + @Test + void findMain_prefersDosOverWindows(@TempDir Path dir) throws Exception { + // Windows PE executable: MZ header + PE signature at offset 0x80 + byte[] windowsExe = new byte[0x100]; + windowsExe[0] = 0x4D; windowsExe[1] = 0x5A; // MZ + windowsExe[0x3C] = (byte) 0x80; // PE offset at 0x80 + windowsExe[0x80] = 0x50; windowsExe[0x81] = 0x45; // "PE" signature + Files.write(dir.resolve("WIN.EXE"), windowsExe); + + // DOS executable: just MZ header, no PE + byte[] dosExe = new byte[0x40]; + dosExe[0] = 0x4D; dosExe[1] = 0x5A; // MZ + Files.write(dir.resolve("DOS.EXE"), dosExe); + + String main = detector.findMain(dir); + assertNotNull(main); + assertTrue(main.endsWith("DOS.EXE"), + "DOS executable should be preferred over Windows, but got: " + main); + } + + @Test + void findMain_returnsNullForNoExecutables(@TempDir Path dir) throws Exception { + Files.writeString(dir.resolve("readme.txt"), "text"); + Files.writeString(dir.resolve("notes.md"), "markdown"); + assertNull(detector.findMain(dir)); + } + + @Test + void findMain_skipsSelfExtractorsWithPkSignature(@TempDir Path dir) throws Exception { + // Self-extractor with PK\x03\x04 signature bytes (ZIP local header) + byte[] sfx = new byte[0x80]; + sfx[0] = 0x4D; sfx[1] = 0x5A; // MZ header + sfx[0x60] = 0x50; sfx[0x61] = 0x4B; sfx[0x62] = 0x03; sfx[0x63] = 0x04; // PK\x03\x04 + Files.write(dir.resolve("SFX.EXE"), sfx); + + // Real game executable + Files.write(dir.resolve("GAME.EXE"), createMZ(2000)); + + String main = detector.findMain(dir); + assertNotNull(main); + assertTrue(main.endsWith("GAME.EXE"), + "Self-extractor with PK signature bytes should be skipped, but got: " + main); + } + + @Test + void findMain_handlesMixedContent(@TempDir Path dir) throws Exception { + Files.writeString(dir.resolve("readme.txt"), "text"); + Files.write(dir.resolve("DOS4GW.EXE"), createMZ(200)); // extender, skipped + Files.write(dir.resolve("INSTALL.EXE"), createMZ(150)); // installer, deprioritized + Files.write(dir.resolve("GAME.EXE"), createMZ(5000)); // real game, should win + Files.write(dir.resolve("helper.com"), createMZ(100)); // small .com + + String main = detector.findMain(dir); + assertNotNull(main); + assertTrue(main.endsWith("GAME.EXE"), + "Main game executable should be selected, but got: " + main); + } + + @Test + void findAll_returnsEmptyForEmptyDir(@TempDir Path dir) throws Exception { + assertTrue(detector.findAll(dir).isEmpty()); + } + + private static byte[] createMZ(int size) { + byte[] buf = new byte[size]; + buf[0] = 0x4D; // M + buf[1] = 0x5A; // Z + return buf; + } +} diff --git a/src/test/java/org/dostalgia/FileUtilsTest.java b/src/test/java/org/dostalgia/FileUtilsTest.java new file mode 100644 index 0000000..060ddbf --- /dev/null +++ b/src/test/java/org/dostalgia/FileUtilsTest.java @@ -0,0 +1,94 @@ +package org.dostalgia; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +class FileUtilsTest { + + @Test + void deleteDirectory_nonExistentDir_doesNotThrow(@TempDir Path dir) { + Path nonExistent = dir.resolve("doesnotexist"); + assertDoesNotThrow(() -> FileUtils.deleteDirectory(nonExistent)); + } + + @Test + void deleteDirectory_deletesFilesRecursively(@TempDir Path dir) throws Exception { + Files.writeString(dir.resolve("file1.txt"), "hello"); + Files.writeString(dir.resolve("file2.txt"), "world"); + Path sub = Files.createDirectories(dir.resolve("sub")); + Files.writeString(sub.resolve("nested.txt"), "deep"); + + assertTrue(Files.exists(dir.resolve("file1.txt"))); + FileUtils.deleteDirectory(dir); + assertFalse(Files.exists(dir)); + } + + @Test + void deleteDirectory_deletesSubdirectories(@TempDir Path dir) throws Exception { + Path a = Files.createDirectories(dir.resolve("a/b/c")); + Files.writeString(a.resolve("deep.txt"), "deep"); + Path sub2 = Files.createDirectories(dir.resolve("sub2")); + Files.writeString(sub2.resolve("file.txt"), "data"); + + FileUtils.deleteDirectory(dir); + assertFalse(Files.exists(dir)); + } + + @Test + void findFilesByExtensions_findsExeComBat(@TempDir Path dir) throws Exception { + Files.writeString(dir.resolve("game.exe"), "data"); + Files.writeString(dir.resolve("helper.com"), "data"); + Files.writeString(dir.resolve("run.bat"), "data"); + Files.writeString(dir.resolve("readme.txt"), "data"); + + List result = FileUtils.findFilesByExtensions(dir, Set.of(".exe", ".com", ".bat")); + assertEquals(3, result.size()); + assertTrue(result.contains("game.exe")); + assertTrue(result.contains("helper.com")); + assertTrue(result.contains("run.bat")); + } + + @Test + void findFilesByExtensions_caseInsensitive(@TempDir Path dir) throws Exception { + Files.writeString(dir.resolve("GAME.EXE"), "data"); + Files.writeString(dir.resolve("Helper.Com"), "data"); + Files.writeString(dir.resolve("RUN.BAT"), "data"); + + List result = FileUtils.findFilesByExtensions(dir, Set.of(".exe", ".com", ".bat")); + assertEquals(3, result.size()); + } + + @Test + void findFilesByExtensions_emptyDir_returnsEmpty(@TempDir Path dir) throws Exception { + List result = FileUtils.findFilesByExtensions(dir, Set.of(".exe")); + assertTrue(result.isEmpty()); + } + + @Test + void findFilesByExtensions_noMatches_returnsEmpty(@TempDir Path dir) throws Exception { + Files.writeString(dir.resolve("readme.txt"), "data"); + Files.writeString(dir.resolve("notes.md"), "data"); + + List result = FileUtils.findFilesByExtensions(dir, Set.of(".exe", ".com", ".bat")); + assertTrue(result.isEmpty()); + } + + @Test + void findFilesByExtensions_includesSubdirectories(@TempDir Path dir) throws Exception { + Path sub = Files.createDirectories(dir.resolve("subdir")); + Files.writeString(sub.resolve("game.exe"), "data"); + Files.writeString(dir.resolve("root.exe"), "data"); + + List result = FileUtils.findFilesByExtensions(dir, Set.of(".exe")); + assertEquals(2, result.size()); + assertTrue(result.contains("root.exe")); + assertTrue(result.contains("subdir/game.exe")); + } +} diff --git a/src/test/java/org/dostalgia/GameServiceTest.java b/src/test/java/org/dostalgia/GameServiceTest.java new file mode 100644 index 0000000..35be9f4 --- /dev/null +++ b/src/test/java/org/dostalgia/GameServiceTest.java @@ -0,0 +1,291 @@ +package org.dostalgia; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class GameServiceTest { + + private static final ObjectMapper MAPPER = new ObjectMapper() + .registerModule(new JavaTimeModule()) + .setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE) + .disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + .enable(SerializationFeature.INDENT_OUTPUT); + + // -- sanitizeId tests -- + + @Test + void sanitizeId_normalString_kept() { + assertEquals("hello", GameService.sanitizeId("hello")); + } + + @Test + void sanitizeId_uppercaseToLowercase() { + assertEquals("hello", GameService.sanitizeId("HELLO")); + } + + @Test + void sanitizeId_spacesToHyphens() { + assertEquals("hello-world", GameService.sanitizeId("hello world")); + } + + @Test + void sanitizeId_specialCharsStripped() { + assertEquals("helloworld", GameService.sanitizeId("hello!@#$world")); + } + + @Test + void sanitizeId_tripleHyphenStripped() { + assertEquals("test", GameService.sanitizeId("---test---")); + } + + @Test + void sanitizeId_emptyReturnsUnknown() { + assertEquals("unknown", GameService.sanitizeId("")); + } + + @Test + void sanitizeId_onlySpecialCharsReturnsUnknown() { + assertEquals("unknown", GameService.sanitizeId("!@#$%^&*()")); + } + + @Test + void sanitizeId_mixedCaseAndSpaces() { + assertEquals("doom-ii-hell-on-earth", GameService.sanitizeId("DOOM II: Hell on Earth")); + } + + @Test + void sanitizeId_underscoresToHyphens() { + assertEquals("my-game", GameService.sanitizeId("my_game")); + } + + @Test + void sanitizeId_numbersPreserved() { + assertEquals("game2", GameService.sanitizeId("Game2")); + } + + // -- gameDir / gameJsonPath tests -- + + @Test + void gameDir_returnsCorrectPath(@TempDir Path tempDir) throws Exception { + GameService svc = new GameService(); + svc.dataDir = tempDir.toString(); + svc.init(); + + Path dir = svc.gameDir("test-game"); + assertEquals(tempDir.resolve("games/test-game"), dir); + } + + @Test + void gameJsonPath_returnsCorrectPath(@TempDir Path tempDir) throws Exception { + GameService svc = new GameService(); + svc.dataDir = tempDir.toString(); + svc.init(); + + Path path = svc.gameJsonPath("test-game"); + assertEquals(tempDir.resolve("games/test-game/game.json"), path); + } + + // -- init tests -- + + @Test + void init_createsDirectories(@TempDir Path tempDir) throws Exception { + GameService svc = new GameService(); + svc.dataDir = tempDir.toString(); + svc.init(); + + assertTrue(Files.isDirectory(tempDir.resolve("games"))); + assertTrue(Files.isDirectory(tempDir.resolve("artwork"))); + assertTrue(Files.isDirectory(tempDir.resolve("saves"))); + } + + // -- save / load tests -- + + @Test + void saveAndLoad_roundTrip(@TempDir Path tempDir) throws Exception { + GameService svc = new GameService(); + svc.dataDir = tempDir.toString(); + svc.init(); + + Game game = new Game("test-game", "Test Game"); + game.setYear(1995); + game.setGenre("FPS"); + svc.save(game); + + Game loaded = svc.load("test-game"); + assertEquals("test-game", loaded.getId()); + assertEquals("Test Game", loaded.getTitle()); + assertEquals(1995, loaded.getYear()); + assertEquals("FPS", loaded.getGenre()); + assertNotNull(loaded.getCreatedAt()); + assertNotNull(loaded.getUpdatedAt()); + } + + @Test + void save_updatesUpdatedAt(@TempDir Path tempDir) throws Exception { + GameService svc = new GameService(); + svc.dataDir = tempDir.toString(); + svc.init(); + + Game game = new Game("game1", "Game One"); + svc.save(game); + Thread.sleep(10); // ensure timestamp advances + + game.setGenre("Adventure"); + svc.save(game); + + Game loaded = svc.load("game1"); + assertNotNull(loaded.getUpdatedAt()); + assertTrue(loaded.getUpdatedAt().isAfter(loaded.getCreatedAt()) + || loaded.getUpdatedAt().equals(loaded.getCreatedAt()), + "updatedAt should be >= createdAt"); + } + + @Test + void load_nonExistent_throwsNoSuchFileException(@TempDir Path tempDir) throws Exception { + GameService svc = new GameService(); + svc.dataDir = tempDir.toString(); + svc.init(); + + assertThrows(NoSuchFileException.class, () -> svc.load("nonexistent")); + } + + // -- list tests -- + + @Test + void list_emptyDir_returnsEmpty(@TempDir Path tempDir) throws Exception { + GameService svc = new GameService(); + svc.dataDir = tempDir.toString(); + svc.init(); + + List games = svc.list(); + assertTrue(games.isEmpty()); + } + + @Test + void saveAndList_returnsGame(@TempDir Path tempDir) throws Exception { + GameService svc = new GameService(); + svc.dataDir = tempDir.toString(); + svc.init(); + + svc.save(new Game("game1", "Game One")); + svc.save(new Game("game2", "Game Two")); + + List games = svc.list(); + assertEquals(2, games.size()); + } + + @Test + void list_sortsByTitle(@TempDir Path tempDir) throws Exception { + GameService svc = new GameService(); + svc.dataDir = tempDir.toString(); + svc.init(); + + svc.save(new Game("zzz", "Z Game")); + svc.save(new Game("aaa", "A Game")); + + List games = svc.list(); + assertEquals(2, games.size()); + assertEquals("A Game", games.get(0).getTitle()); + assertEquals("Z Game", games.get(1).getTitle()); + } + + // -- delete tests -- + + @Test + void delete_removesGameDir(@TempDir Path tempDir) throws Exception { + GameService svc = new GameService(); + svc.dataDir = tempDir.toString(); + svc.init(); + + svc.save(new Game("game1", "Game One")); + assertTrue(Files.exists(svc.gameDir("game1"))); + + svc.delete("game1"); + assertFalse(Files.exists(svc.gameDir("game1"))); + } + + @Test + void delete_nonExistent_doesNotThrow(@TempDir Path tempDir) throws Exception { + GameService svc = new GameService(); + svc.dataDir = tempDir.toString(); + svc.init(); + + assertDoesNotThrow(() -> svc.delete("nonexistent")); + } + + @Test + void delete_removesOldJsdosFiles(@TempDir Path tempDir) throws Exception { + GameService svc = new GameService(); + svc.dataDir = tempDir.toString(); + svc.init(); + + // Create backward-compat files that delete should clean up + Files.writeString(tempDir.resolve("games/game1.jsdos"), "old"); + Files.writeString(tempDir.resolve("games/game1.setup.jsdos"), "old-setup"); + + svc.delete("game1"); + + assertFalse(Files.exists(tempDir.resolve("games/game1.jsdos"))); + assertFalse(Files.exists(tempDir.resolve("games/game1.setup.jsdos"))); + } + + // -- JSON serialization tests -- + + @Test + void gameSerialization_snakeCase(@TempDir Path tempDir) throws Exception { + Game g = new Game("my-game", "My Game"); + g.setIgdbCoverId("abc123"); + g.setHasCover(true); + g.setHasScreenshots(false); + g.setIgdbId(42); + g.setBundleSize(1024L); + g.setSetupExe("setup.exe"); + + String json = MAPPER.writeValueAsString(g); + assertTrue(json.contains("\"igdb_cover_id\"")); + assertTrue(json.contains("\"has_cover\"")); + assertTrue(json.contains("\"has_screenshots\"")); + assertTrue(json.contains("\"bundle_size\"")); + assertTrue(json.contains("\"setup_exe\"")); + assertFalse(json.contains("igdbCoverId")); + assertFalse(json.contains("hasCover")); + assertFalse(json.contains("hasScreenshots")); + assertFalse(json.contains("bundleSize")); + assertFalse(json.contains("setupExe")); + + Game deserialized = MAPPER.readValue(json, Game.class); + assertEquals("my-game", deserialized.getId()); + assertEquals("My Game", deserialized.getTitle()); + assertEquals("abc123", deserialized.getIgdbCoverId()); + assertTrue(deserialized.isHasCover()); + assertFalse(deserialized.isHasScreenshots()); + assertEquals(Integer.valueOf(42), deserialized.getIgdbId()); + assertEquals(Long.valueOf(1024L), deserialized.getBundleSize()); + assertEquals("setup.exe", deserialized.getSetupExe()); + } + + @Test + void gameSerialization_timestamps(@TempDir Path tempDir) throws Exception { + Game g = new Game("ts-game", "Timestamp Test"); + String json = MAPPER.writeValueAsString(g); + + assertTrue(json.contains("\"created_at\"")); + assertTrue(json.contains("\"updated_at\"")); + + Game deserialized = MAPPER.readValue(json, Game.class); + assertNotNull(deserialized.getCreatedAt()); + assertNotNull(deserialized.getUpdatedAt()); + } +} diff --git a/src/test/java/org/dostalgia/GameTest.java b/src/test/java/org/dostalgia/GameTest.java new file mode 100644 index 0000000..9f84b9e --- /dev/null +++ b/src/test/java/org/dostalgia/GameTest.java @@ -0,0 +1,154 @@ +package org.dostalgia; + +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class GameTest { + + @Test + void noArgConstructor_setsNothing() { + Game g = new Game(); + assertNull(g.getId()); + assertNull(g.getTitle()); + assertNull(g.getYear()); + assertNull(g.getGenre()); + assertNull(g.getDeveloper()); + assertNull(g.getPublisher()); + assertNull(g.getDescription()); + assertNull(g.getRating()); + assertNull(g.getBundleFile()); + assertNull(g.getSetupExe()); + assertNull(g.getBundleSize()); + assertNull(g.getExecutable()); + assertNull(g.getExecutables()); + assertNull(g.getPlatform()); + assertNull(g.getIgdbId()); + assertNull(g.getIgdbCoverId()); + assertFalse(g.isHasCover()); + assertFalse(g.isHasScreenshots()); + assertNull(g.getScreenshots()); + assertNull(g.getVideos()); + assertFalse(g.isReady()); + assertNull(g.getCreatedAt()); + assertNull(g.getUpdatedAt()); + } + + @Test + void twoArgConstructor_setsIdTitleAndTimestamps() { + Game g = new Game("test-id", "Test Game"); + assertEquals("test-id", g.getId()); + assertEquals("Test Game", g.getTitle()); + assertFalse(g.isReady()); + assertNotNull(g.getCreatedAt()); + assertNotNull(g.getUpdatedAt()); + } + + @Test + void settersAndGetters_roundTrip() { + Game g = new Game(); + g.setId("id1"); + g.setTitle("Title"); + g.setYear(1995); + g.setGenre("Action"); + g.setDeveloper("DevCo"); + g.setPublisher("PubCo"); + g.setDescription("A great game"); + g.setRating(4.5); + g.setBundleFile("game.zip"); + g.setSetupExe("setup.exe"); + g.setBundleSize(12345L); + g.setExecutable("game.exe"); + g.setExecutables(List.of("game.exe", "setup.exe")); + g.setPlatform("dos"); + g.setIgdbId(42); + g.setIgdbCoverId("abc123"); + g.setHasCover(true); + g.setHasScreenshots(true); + g.setScreenshots(List.of("shot1.png")); + g.setVideos(List.of("vid1")); + g.setReady(true); + Instant now = Instant.now(); + g.setCreatedAt(now); + g.setUpdatedAt(now); + + assertEquals("id1", g.getId()); + assertEquals("Title", g.getTitle()); + assertEquals(1995, g.getYear()); + assertEquals("Action", g.getGenre()); + assertEquals("DevCo", g.getDeveloper()); + assertEquals("PubCo", g.getPublisher()); + assertEquals("A great game", g.getDescription()); + assertEquals(4.5, g.getRating()); + assertEquals("game.zip", g.getBundleFile()); + assertEquals("setup.exe", g.getSetupExe()); + assertEquals(12345L, g.getBundleSize()); + assertEquals("game.exe", g.getExecutable()); + assertEquals(List.of("game.exe", "setup.exe"), g.getExecutables()); + assertEquals("dos", g.getPlatform()); + assertEquals(42, g.getIgdbId()); + assertEquals("abc123", g.getIgdbCoverId()); + assertTrue(g.isHasCover()); + assertTrue(g.isHasScreenshots()); + assertEquals(List.of("shot1.png"), g.getScreenshots()); + assertEquals(List.of("vid1"), g.getVideos()); + assertTrue(g.isReady()); + assertEquals(now, g.getCreatedAt()); + assertEquals(now, g.getUpdatedAt()); + } + + @Test + void isHasSetup_nullSetupExe_returnsFalse() { + Game g = new Game(); + g.setSetupExe(null); + assertFalse(g.isHasSetup()); + } + + @Test + void isHasSetup_blankSetupExe_returnsFalse() { + Game g = new Game(); + g.setSetupExe(" "); + assertFalse(g.isHasSetup()); + } + + @Test + void isHasSetup_nonBlankSetupExe_returnsTrue() { + Game g = new Game(); + g.setSetupExe("setup.exe"); + assertTrue(g.isHasSetup()); + } + + @Test + void isPlayable_notReady_returnsFalse() { + Game g = new Game(); + g.setReady(false); + assertFalse(g.isPlayable()); + } + + @Test + void isPlayable_readyNullPlatform_returnsTrue() { + Game g = new Game(); + g.setReady(true); + g.setPlatform(null); + assertTrue(g.isPlayable()); + } + + @Test + void isPlayable_readyDosPlatform_returnsTrue() { + Game g = new Game(); + g.setReady(true); + g.setPlatform("dos"); + assertTrue(g.isPlayable()); + } + + @Test + void isPlayable_readyWindowsPlatform_returnsFalse() { + Game g = new Game(); + g.setReady(true); + g.setPlatform("windows"); + assertFalse(g.isPlayable()); + } +} diff --git a/src/test/java/org/dostalgia/IgdbServiceTest.java b/src/test/java/org/dostalgia/IgdbServiceTest.java new file mode 100644 index 0000000..d32d8c4 --- /dev/null +++ b/src/test/java/org/dostalgia/IgdbServiceTest.java @@ -0,0 +1,265 @@ +package org.dostalgia; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; + +class IgdbServiceTest { + + private IgdbService service; + private ObjectMapper mapper; + + private Method epochToYearMethod; + private Method extractGenresMethod; + private Method sanitizeMethod; + private Method jsonNodeToMapMethod; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() throws Exception { + service = new IgdbService(); + service.clientId = Optional.of("test-client-id"); + service.clientSecret = Optional.of("test-client-secret"); + mapper = new ObjectMapper(); + + epochToYearMethod = IgdbService.class.getDeclaredMethod("epochToYear", JsonNode.class); + epochToYearMethod.setAccessible(true); + + extractGenresMethod = IgdbService.class.getDeclaredMethod("extractGenres", JsonNode.class); + extractGenresMethod.setAccessible(true); + + sanitizeMethod = IgdbService.class.getDeclaredMethod("sanitize", String.class); + sanitizeMethod.setAccessible(true); + + jsonNodeToMapMethod = IgdbService.class.getDeclaredMethod("jsonNodeToMap", JsonNode.class); + jsonNodeToMapMethod.setAccessible(true); + } + + // -- isConfigured tests -- + + @Test + void isConfigured_returnsTrueWhenBothPresentAndNonBlank() { + assertTrue(service.isConfigured()); + } + + @Test + void isConfigured_returnsFalseWhenClientIdEmpty() { + service.clientId = Optional.empty(); + assertFalse(service.isConfigured()); + } + + @Test + void isConfigured_returnsFalseWhenClientSecretEmpty() { + service.clientSecret = Optional.empty(); + assertFalse(service.isConfigured()); + } + + @Test + void isConfigured_returnsFalseWhenClientIdBlank() { + service.clientId = Optional.of(""); + assertFalse(service.isConfigured()); + } + + @Test + void isConfigured_returnsFalseWhenClientSecretBlank() { + service.clientSecret = Optional.of(""); + assertFalse(service.isConfigured()); + } + + // -- epochToYear tests -- + + @Test + void epochToYear_withReleaseDate_returnsYear() throws Exception { + // 946684800 = 2000-01-01T00:00:00Z + JsonNode node = mapper.readTree("{\"first_release_date\": 946684800}"); + Optional year = (Optional) epochToYearMethod.invoke(null, node); + assertTrue(year.isPresent()); + int y = year.get(); + assertTrue(y >= 1999 && y <= 2001, "Year should be around 2000 but was " + y); + } + + @Test + void epochToYear_withoutReleaseDate_returnsEmpty() throws Exception { + JsonNode node = mapper.readTree("{\"name\": \"Test Game\"}"); + Optional year = (Optional) epochToYearMethod.invoke(null, node); + assertFalse(year.isPresent()); + } + + @Test + void epochToYear_withEpochZero_returnsYear() throws Exception { + // epoch 0 = 1970-01-01T00:00:00Z + JsonNode node = mapper.readTree("{\"first_release_date\": 0}"); + Optional year = (Optional) epochToYearMethod.invoke(null, node); + assertTrue(year.isPresent()); + assertEquals(1970, year.get()); + } + + @Test + void epochToYear_negativeEpoch_returnsYear() throws Exception { + // negative epoch is before 1970 + JsonNode node = mapper.readTree("{\"first_release_date\": -315619200}"); + Optional year = (Optional) epochToYearMethod.invoke(null, node); + assertTrue(year.isPresent()); + // Calendar may handle negative values differently; just verify it returns something + assertNotNull(year.get()); + } + + // -- extractGenres tests -- + + @Test + void extractGenres_withGenresArray_returnsList() throws Exception { + JsonNode node = mapper.readTree("{\"genres\": [{\"name\": \"Action\"}, {\"name\": \"Adventure\"}]}"); + List genres = (List) extractGenresMethod.invoke(null, node); + assertEquals(2, genres.size()); + assertTrue(genres.contains("Action")); + assertTrue(genres.contains("Adventure")); + } + + @Test + void extractGenres_emptyArray_returnsEmptyList() throws Exception { + JsonNode node = mapper.readTree("{\"genres\": []}"); + List genres = (List) extractGenresMethod.invoke(null, node); + assertTrue(genres.isEmpty()); + } + + @Test + void extractGenres_missingField_returnsEmptyList() throws Exception { + JsonNode node = mapper.readTree("{\"name\": \"Test\"}"); + List genres = (List) extractGenresMethod.invoke(null, node); + assertTrue(genres.isEmpty()); + } + + @Test + void extractGenres_genreWithoutName_skipsEntry() throws Exception { + JsonNode node = mapper.readTree("{\"genres\": [{\"name\": \"Action\"}, {\"id\": 5}]}"); + List genres = (List) extractGenresMethod.invoke(null, node); + assertEquals(1, genres.size()); + assertEquals("Action", genres.get(0)); + } + + // -- sanitize tests -- + + @Test + void sanitize_escapesBackslashes() throws Exception { + String result = (String) sanitizeMethod.invoke(service, "path\\to\\file"); + assertEquals("path\\\\to\\\\file", result); + } + + @Test + void sanitize_escapesQuotes() throws Exception { + String result = (String) sanitizeMethod.invoke(service, "say \"hello\""); + assertEquals("say \\\"hello\\\"", result); + } + + @Test + void sanitize_escapesBothBackslashesAndQuotes() throws Exception { + String result = (String) sanitizeMethod.invoke(service, "\\\"mixed\\\""); + assertEquals("\\\\\\\"mixed\\\\\\\"", result); + } + + @Test + void sanitize_normalString_unchanged() throws Exception { + String result = (String) sanitizeMethod.invoke(service, "hello world"); + assertEquals("hello world", result); + } + + @Test + void sanitize_emptyString_unchanged() throws Exception { + String result = (String) sanitizeMethod.invoke(service, ""); + assertEquals("", result); + } + + // -- jsonNodeToMap tests -- + + @Test + void jsonNodeToMap_basicFields() throws Exception { + JsonNode node = mapper.readTree("{\"id\": 123, \"name\": \"Test Game\"}"); + Map map = (Map) jsonNodeToMapMethod.invoke(service, node); + assertEquals(123, map.get("igdb_id")); + assertEquals("Test Game", map.get("name")); + } + + @Test + void jsonNodeToMap_withGenres() throws Exception { + JsonNode node = mapper.readTree("{\"id\": 1, \"name\": \"G\", \"genres\": [{\"name\": \"RPG\"}]}"); + Map map = (Map) jsonNodeToMapMethod.invoke(service, node); + assertTrue(map.containsKey("genres")); + assertEquals(List.of("RPG"), map.get("genres")); + } + + @Test + void jsonNodeToMap_withInvolvedCompanies() throws Exception { + JsonNode node = mapper.readTree(""" + { + "id": 1, + "name": "G", + "involved_companies": [ + { "company": { "name": "DevStudio" }, "developer": true, "publisher": false }, + { "company": { "name": "PubStudio" }, "developer": false, "publisher": true } + ] + } + """); + Map map = (Map) jsonNodeToMapMethod.invoke(service, node); + assertEquals("DevStudio", map.get("developer")); + assertEquals("PubStudio", map.get("publisher")); + } + + @Test + void jsonNodeToMap_withSummary() throws Exception { + JsonNode node = mapper.readTree("{\"id\": 1, \"name\": \"G\", \"summary\": \"A great game\"}"); + Map map = (Map) jsonNodeToMapMethod.invoke(service, node); + assertEquals("A great game", map.get("summary")); + } + + @Test + void jsonNodeToMap_withCoverUrl() throws Exception { + JsonNode node = mapper.readTree("{\"id\": 1, \"name\": \"G\", \"cover\": { \"url\": \"//images.igdb.com/igdb/image/upload/t_thumb/abc.jpg\" }}"); + Map map = (Map) jsonNodeToMapMethod.invoke(service, node); + String coverUrl = (String) map.get("cover_url"); + assertNotNull(coverUrl); + assertTrue(coverUrl.startsWith("https:")); + assertTrue(coverUrl.contains("t_cover_big")); + } + + @Test + void jsonNodeToMap_emptyObject_returnsDefaults() throws Exception { + JsonNode node = mapper.readTree("{}"); + Map map = (Map) jsonNodeToMapMethod.invoke(service, node); + assertEquals(0, map.get("igdb_id")); + assertEquals("", map.get("name")); + } + + @Test + void jsonNodeToMap_noInvolvedCompanies_skipsDevPub() throws Exception { + JsonNode node = mapper.readTree("{\"id\": 1, \"name\": \"G\"}"); + Map map = (Map) jsonNodeToMapMethod.invoke(service, node); + assertFalse(map.containsKey("developer")); + assertFalse(map.containsKey("publisher")); + } + + @Test + void jsonNodeToMap_firstDevAndPubOnly() throws Exception { + // Multiple developers/publishers — only first of each should be recorded + JsonNode node = mapper.readTree(""" + { + "id": 1, "name": "G", + "involved_companies": [ + { "company": { "name": "FirstDev" }, "developer": true, "publisher": false }, + { "company": { "name": "SecondDev" }, "developer": true, "publisher": false }, + { "company": { "name": "FirstPub" }, "developer": false, "publisher": true } + ] + } + """); + Map map = (Map) jsonNodeToMapMethod.invoke(service, node); + assertEquals("FirstDev", map.get("developer")); + assertEquals("FirstPub", map.get("publisher")); + } +} diff --git a/src/test/java/org/dostalgia/PlatformDetectorTest.java b/src/test/java/org/dostalgia/PlatformDetectorTest.java new file mode 100644 index 0000000..108dd2c --- /dev/null +++ b/src/test/java/org/dostalgia/PlatformDetectorTest.java @@ -0,0 +1,92 @@ +package org.dostalgia; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class PlatformDetectorTest { + + private final PlatformDetector detector = new PlatformDetector(); + + @Test + void detect_comFile_returnsDos(@TempDir Path dir) throws Exception { + Path f = dir.resolve("test.com"); + Files.write(f, new byte[]{0x4D, 0x5A}); // MZ header + assertEquals("dos", detector.detect(f)); + } + + @Test + void detect_batFile_returnsDos(@TempDir Path dir) throws Exception { + Path f = dir.resolve("test.bat"); + Files.writeString(f, "@echo off"); + assertEquals("dos", detector.detect(f)); + } + + @Test + void detect_plainMZ_returnsDos(@TempDir Path dir) throws Exception { + byte[] buf = new byte[0x80]; + buf[0] = 0x4D; // M + buf[1] = 0x5A; // Z + // e_lfanew points to garbage — should be treated as plain DOS + buf[0x3C] = 0x00; + buf[0x3D] = 0x00; + buf[0x3E] = 0x00; + buf[0x3F] = 0x00; + + Path f = dir.resolve("test.exe"); + Files.write(f, buf); + assertEquals("dos", detector.detect(f)); + } + + @Test + void detect_peHeader_returnsWindows(@TempDir Path dir) throws Exception { + byte[] buf = new byte[0x100]; + buf[0] = 0x4D; // M + buf[1] = 0x5A; // Z + // e_lfanew = 0x80 + buf[0x3C] = (byte) 0x80; + buf[0x3D] = 0x00; + buf[0x3E] = 0x00; + buf[0x3F] = 0x00; + // PE signature at offset 0x80 + buf[0x80] = 0x50; // P + buf[0x81] = 0x45; // E + + Path f = dir.resolve("test.exe"); + Files.write(f, buf); + assertEquals("windows", detector.detect(f)); + } + + @Test + void detect_neHeader_returnsWindows(@TempDir Path dir) throws Exception { + byte[] buf = new byte[0x100]; + buf[0] = 0x4D; + buf[1] = 0x5A; + buf[0x3C] = (byte) 0x80; + buf[0x80] = 0x4E; // N + buf[0x81] = 0x45; // E + + Path f = dir.resolve("test.exe"); + Files.write(f, buf); + assertEquals("windows", detector.detect(f)); + } + + @Test + void detect_emptyFile_returnsNull(@TempDir Path dir) throws Exception { + Path f = dir.resolve("empty.exe"); + Files.write(f, new byte[0]); + assertNull(detector.detect(f)); + } + + @Test + void detect_noMZ_returnsNull(@TempDir Path dir) throws Exception { + Path f = dir.resolve("bad.exe"); + Files.write(f, new byte[]{0x00, 0x00}); + assertNull(detector.detect(f)); + } +} diff --git a/src/test/java/org/dostalgia/StaticResourceTest.java b/src/test/java/org/dostalgia/StaticResourceTest.java new file mode 100644 index 0000000..c967e3b --- /dev/null +++ b/src/test/java/org/dostalgia/StaticResourceTest.java @@ -0,0 +1,198 @@ +package org.dostalgia; + +import jakarta.ws.rs.core.Response; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class StaticResourceTest { + + @Test + void health_returnsOkStatusAndVersion(@TempDir Path tempDir) { + StaticResource sr = new StaticResource(); + sr.dataDir = tempDir.toString(); + + Response resp = sr.health(); + assertEquals(200, resp.getStatus()); + Object entity = resp.getEntity(); + assertInstanceOf(Map.class, entity); + Map map = (Map) entity; + assertEquals("ok", map.get("status")); + assertEquals("0.1.0", map.get("version")); + } + + @Test + void getGameFile_jsdosContentType(@TempDir Path tempDir) throws Exception { + Path gamesDir = Files.createDirectories(tempDir.resolve("games")); + Files.writeString(gamesDir.resolve("test.jsdos"), "zip content"); + + StaticResource sr = new StaticResource(); + sr.dataDir = tempDir.toString(); + + Response resp = sr.getGameFile("test.jsdos"); + assertEquals(200, resp.getStatus()); + assertNotNull(resp.getMediaType()); + assertEquals("application/zip", resp.getMediaType().toString()); + } + + @Test + void getGameFile_zipContentType(@TempDir Path tempDir) throws Exception { + Path gamesDir = Files.createDirectories(tempDir.resolve("games")); + Files.writeString(gamesDir.resolve("archive.zip"), "zip content"); + + StaticResource sr = new StaticResource(); + sr.dataDir = tempDir.toString(); + + Response resp = sr.getGameFile("archive.zip"); + assertEquals(200, resp.getStatus()); + assertNotNull(resp.getMediaType()); + assertEquals("application/zip", resp.getMediaType().toString()); + } + + @Test + void getGameFile_jpgContentType(@TempDir Path tempDir) throws Exception { + Path gamesDir = Files.createDirectories(tempDir.resolve("games")); + Files.writeString(gamesDir.resolve("cover.jpg"), "image data"); + + StaticResource sr = new StaticResource(); + sr.dataDir = tempDir.toString(); + + Response resp = sr.getGameFile("cover.jpg"); + assertEquals(200, resp.getStatus()); + assertNotNull(resp.getMediaType()); + assertEquals("image/jpeg", resp.getMediaType().toString()); + } + + @Test + void getGameFile_jpegContentType(@TempDir Path tempDir) throws Exception { + Path gamesDir = Files.createDirectories(tempDir.resolve("games")); + Files.writeString(gamesDir.resolve("photo.jpeg"), "image data"); + + StaticResource sr = new StaticResource(); + sr.dataDir = tempDir.toString(); + + Response resp = sr.getGameFile("photo.jpeg"); + assertEquals(200, resp.getStatus()); + assertNotNull(resp.getMediaType()); + assertEquals("image/jpeg", resp.getMediaType().toString()); + } + + @Test + void getGameFile_pngContentType(@TempDir Path tempDir) throws Exception { + Path gamesDir = Files.createDirectories(tempDir.resolve("games")); + Files.writeString(gamesDir.resolve("screenshot.png"), "png data"); + + StaticResource sr = new StaticResource(); + sr.dataDir = tempDir.toString(); + + Response resp = sr.getGameFile("screenshot.png"); + assertEquals(200, resp.getStatus()); + assertNotNull(resp.getMediaType()); + assertEquals("image/png", resp.getMediaType().toString()); + } + + @Test + void getGameFile_webpContentType(@TempDir Path tempDir) throws Exception { + Path gamesDir = Files.createDirectories(tempDir.resolve("games")); + Files.writeString(gamesDir.resolve("image.webp"), "webp data"); + + StaticResource sr = new StaticResource(); + sr.dataDir = tempDir.toString(); + + Response resp = sr.getGameFile("image.webp"); + assertEquals(200, resp.getStatus()); + assertNotNull(resp.getMediaType()); + assertEquals("image/webp", resp.getMediaType().toString()); + } + + @Test + void getGameFile_gifContentType(@TempDir Path tempDir) throws Exception { + Path gamesDir = Files.createDirectories(tempDir.resolve("games")); + Files.writeString(gamesDir.resolve("anim.gif"), "gif data"); + + StaticResource sr = new StaticResource(); + sr.dataDir = tempDir.toString(); + + Response resp = sr.getGameFile("anim.gif"); + assertEquals(200, resp.getStatus()); + assertNotNull(resp.getMediaType()); + assertEquals("image/gif", resp.getMediaType().toString()); + } + + @Test + void getGameFile_jsonContentType(@TempDir Path tempDir) throws Exception { + Path gamesDir = Files.createDirectories(tempDir.resolve("games")); + Files.writeString(gamesDir.resolve("data.json"), "{}"); + + StaticResource sr = new StaticResource(); + sr.dataDir = tempDir.toString(); + + Response resp = sr.getGameFile("data.json"); + assertEquals(200, resp.getStatus()); + assertNotNull(resp.getMediaType()); + assertEquals("application/json", resp.getMediaType().toString()); + } + + @Test + void getGameFile_exeContentType(@TempDir Path tempDir) throws Exception { + Path gamesDir = Files.createDirectories(tempDir.resolve("games")); + Files.writeString(gamesDir.resolve("game.exe"), "MZ"); + + StaticResource sr = new StaticResource(); + sr.dataDir = tempDir.toString(); + + Response resp = sr.getGameFile("game.exe"); + assertEquals(200, resp.getStatus()); + assertNotNull(resp.getMediaType()); + assertEquals("application/octet-stream", resp.getMediaType().toString()); + } + + @Test + void getGameFile_pathTraversal_returns403(@TempDir Path tempDir) { + StaticResource sr = new StaticResource(); + sr.dataDir = tempDir.toString(); + + Response resp = sr.getGameFile("../etc/passwd"); + assertEquals(403, resp.getStatus()); + } + + @Test + void getGameFile_nonExistentFile_returns404(@TempDir Path tempDir) throws Exception { + Files.createDirectories(tempDir.resolve("games")); + + StaticResource sr = new StaticResource(); + sr.dataDir = tempDir.toString(); + + Response resp = sr.getGameFile("nonexistent.exe"); + assertEquals(404, resp.getStatus()); + } + + @Test + void getArtwork_contentTypeAndCacheHeader(@TempDir Path tempDir) throws Exception { + Path artworkDir = Files.createDirectories(tempDir.resolve("artwork")); + Files.writeString(artworkDir.resolve("cover.jpg"), "image data"); + + StaticResource sr = new StaticResource(); + sr.dataDir = tempDir.toString(); + + Response resp = sr.getArtwork("cover.jpg"); + assertEquals(200, resp.getStatus()); + assertNotNull(resp.getMediaType()); + assertEquals("image/jpeg", resp.getMediaType().toString()); + assertTrue(resp.getHeaders().containsKey("Cache-Control")); + } + + @Test + void getArtwork_pathTraversal_returns403(@TempDir Path tempDir) { + StaticResource sr = new StaticResource(); + sr.dataDir = tempDir.toString(); + + Response resp = sr.getArtwork("../../etc/passwd"); + assertEquals(403, resp.getStatus()); + } +} diff --git a/src/test/java/org/dostalgia/ZipServiceTest.java b/src/test/java/org/dostalgia/ZipServiceTest.java new file mode 100644 index 0000000..535167b --- /dev/null +++ b/src/test/java/org/dostalgia/ZipServiceTest.java @@ -0,0 +1,230 @@ +package org.dostalgia; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; +import java.util.zip.ZipOutputStream; + +import static org.junit.jupiter.api.Assertions.*; + +class ZipServiceTest { + + private final ZipService zip = new ZipService(); + + // Wire the config dependency manually (no CDI) + { + zip.config = new ConfigBuilder(); + } + + @Test + void unzip_extractsFiles(@TempDir Path dir) throws Exception { + Path zipFile = dir.resolve("test.zip"); + Path dest = dir.resolve("output"); + + try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(zipFile))) { + zos.putNextEntry(new ZipEntry("game.exe")); + zos.write("MZ".getBytes()); + zos.closeEntry(); + zos.putNextEntry(new ZipEntry("readme.txt")); + zos.write("Hello".getBytes()); + zos.closeEntry(); + } + + zip.unzip(zipFile, dest); + assertTrue(Files.exists(dest.resolve("game.exe"))); + assertTrue(Files.exists(dest.resolve("readme.txt"))); + assertEquals("MZ", Files.readString(dest.resolve("game.exe"))); + assertEquals("Hello", Files.readString(dest.resolve("readme.txt"))); + } + + @Test + void unzip_handlesSubdirectories(@TempDir Path dir) throws Exception { + Path zipFile = dir.resolve("test.zip"); + Path dest = dir.resolve("output"); + + try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(zipFile))) { + zos.putNextEntry(new ZipEntry("subdir/game.exe")); + zos.write("MZ".getBytes()); + zos.closeEntry(); + zos.putNextEntry(new ZipEntry("subdir/nested/deep.txt")); + zos.write("deep".getBytes()); + zos.closeEntry(); + } + + zip.unzip(zipFile, dest); + assertTrue(Files.exists(dest.resolve("subdir/game.exe"))); + assertTrue(Files.exists(dest.resolve("subdir/nested/deep.txt"))); + } + + @Test + void unzip_pathTraversal_skipsMaliciousEntries(@TempDir Path dir) throws Exception { + Path zipFile = dir.resolve("test.zip"); + Path dest = dir.resolve("output"); + Files.createDirectories(dest); + + try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(zipFile))) { + zos.putNextEntry(new ZipEntry("../evil.txt")); + zos.write("malicious".getBytes()); + zos.closeEntry(); + zos.putNextEntry(new ZipEntry("good.txt")); + zos.write("good".getBytes()); + zos.closeEntry(); + } + + zip.unzip(zipFile, dest); + // Malicious entry should be skipped (path traversal) + assertFalse(Files.exists(dir.resolve("evil.txt"))); + // Good entry should be extracted + assertTrue(Files.exists(dest.resolve("good.txt"))); + } + + @Test + void unzip_directoryEntries_createDirs(@TempDir Path dir) throws Exception { + Path zipFile = dir.resolve("test.zip"); + Path dest = dir.resolve("output"); + + try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(zipFile))) { + zos.putNextEntry(new ZipEntry("adir/")); + zos.closeEntry(); + zos.putNextEntry(new ZipEntry("adir/file.txt")); + zos.write("data".getBytes()); + zos.closeEntry(); + } + + zip.unzip(zipFile, dest); + assertTrue(Files.isDirectory(dest.resolve("adir"))); + assertTrue(Files.exists(dest.resolve("adir/file.txt"))); + } + + @Test + void flattenSingleDir_noOpWhenMultipleDirs(@TempDir Path dir) throws Exception { + Files.createDirectories(dir.resolve("sub1")); + Files.createDirectories(dir.resolve("sub2")); + Files.writeString(dir.resolve("sub1/file.txt"), "data"); + + zip.flattenSingleDir(dir); + assertTrue(Files.exists(dir.resolve("sub1/file.txt"))); + assertTrue(Files.isDirectory(dir.resolve("sub1"))); + assertTrue(Files.isDirectory(dir.resolve("sub2"))); + } + + @Test + void flattenSingleDir_noOpWhenNoSingleDir(@TempDir Path dir) throws Exception { + Files.writeString(dir.resolve("file.txt"), "data"); + Files.writeString(dir.resolve("other.txt"), "data"); + + zip.flattenSingleDir(dir); + assertTrue(Files.exists(dir.resolve("file.txt"))); + assertTrue(Files.exists(dir.resolve("other.txt"))); + } + + @Test + void flattenSingleDir_flattensOneDir(@TempDir Path dir) throws Exception { + Path single = Files.createDirectories(dir.resolve("single")); + Files.createDirectories(single.resolve("nested")); + Files.writeString(single.resolve("file.txt"), "data"); + Files.writeString(single.resolve("nested/deep.txt"), "deep"); + + zip.flattenSingleDir(dir); + // Files should now be directly under dir + assertTrue(Files.exists(dir.resolve("file.txt"))); + assertTrue(Files.exists(dir.resolve("nested/deep.txt"))); + // The intermediate single dir should be gone + assertFalse(Files.exists(single)); + } + + @Test + void flattenSingleDir_emptyDir_noOp(@TempDir Path dir) throws Exception { + zip.flattenSingleDir(dir); + assertTrue(Files.exists(dir)); + } + + @Test + void createBundle_createsValidJsdosZip(@TempDir Path dir) throws Exception { + Path extractDir = dir.resolve("extract"); + Files.createDirectories(extractDir.resolve("sub")); + Files.writeString(extractDir.resolve("game.exe"), "MZ"); + Files.writeString(extractDir.resolve("sub/data.bin"), "binary"); + Files.writeString(extractDir.resolve("readme.txt"), "info"); + + Path bundlePath = dir.resolve("output.jsdos"); + + zip.createBundle(extractDir, extractDir.resolve("game.exe").toString(), List.of(), bundlePath); + + assertTrue(Files.exists(bundlePath)); + assertTrue(Files.size(bundlePath) > 0); + + try (ZipInputStream zis = new ZipInputStream(Files.newInputStream(bundlePath))) { + boolean hasGameExe = false; + boolean hasJsdosDir = false; + boolean hasJsdosConf = false; + boolean hasJsdosJson = false; + boolean hasDataBin = false; + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + String name = entry.getName(); + if (name.equals("game.exe")) hasGameExe = true; + if (name.startsWith(".jsdos/")) hasJsdosDir = true; + if (name.equals(".jsdos/dosbox.conf")) hasJsdosConf = true; + if (name.equals(".jsdos/jsdos.json")) hasJsdosJson = true; + if (name.equals("sub/data.bin")) hasDataBin = true; + zis.closeEntry(); + } + assertTrue(hasGameExe, "Should contain game.exe"); + assertTrue(hasJsdosDir, "Should contain .jsdos/ entries"); + assertTrue(hasJsdosConf, "Should contain .jsdos/dosbox.conf"); + assertTrue(hasJsdosJson, "Should contain .jsdos/jsdos.json"); + assertTrue(hasDataBin, "Should contain sub/data.bin"); + } + } + + @Test + void createBundle_respectsSkipExt(@TempDir Path dir) throws Exception { + Path extractDir = dir.resolve("extract"); + Files.createDirectories(extractDir); + Files.writeString(extractDir.resolve("game.exe"), "MZ"); + Files.writeString(extractDir.resolve("cdimage.nrg"), "image"); + Files.writeString(extractDir.resolve("cdimage.mdf"), "image"); + + Path bundlePath = dir.resolve("output.jsdos"); + + zip.createBundle(extractDir, extractDir.resolve("game.exe").toString(), List.of(), bundlePath); + + try (ZipInputStream zis = new ZipInputStream(Files.newInputStream(bundlePath))) { + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + String name = entry.getName(); + assertNotEquals("cdimage.nrg", name, "Should not contain .nrg"); + assertNotEquals("cdimage.mdf", name, "Should not contain .mdf"); + zis.closeEntry(); + } + } + } + + @Test + void createBundle_noExe_createsCdOnlyConfig(@TempDir Path dir) throws Exception { + Path extractDir = dir.resolve("extract"); + Files.createDirectories(extractDir); + Files.writeString(extractDir.resolve("somefile.bin"), "data"); + + Path bundlePath = dir.resolve("output.jsdos"); + + // null exePath triggers CD-only config generation + zip.createBundle(extractDir, null, List.of("cd.iso"), bundlePath); + + try (ZipInputStream zis = new ZipInputStream(Files.newInputStream(bundlePath))) { + boolean hasJsdosConf = false; + ZipEntry entry; + while ((entry = zis.getNextEntry()) != null) { + if (entry.getName().equals(".jsdos/dosbox.conf")) hasJsdosConf = true; + zis.closeEntry(); + } + assertTrue(hasJsdosConf, "CD-only bundle should still have dosbox.conf"); + } + } +}