Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a84ba7f6de | ||
|
|
d45aa8b603 | ||
|
|
d54a44da1d | ||
|
|
bf175b64a2 | ||
|
|
245f499f04 | ||
|
|
c1350387b1 | ||
|
|
5357622c2d | ||
|
|
89194dd94a | ||
|
|
97168ea0e5 | ||
|
|
a2d899be6f | ||
|
|
2d1d8cce46 | ||
|
|
3c3bb5dfbf | ||
|
|
13a7fd9d19 | ||
|
|
4fd43cd17d | ||
|
|
110e7fcd78 | ||
|
|
f3d85f5239 | ||
|
|
9a9d7e3787 | ||
|
|
e531228fa9 | ||
|
|
d1cebec142 | ||
|
|
3c4d3e7216 | ||
|
|
e697638827 | ||
|
|
1306a88b0e | ||
|
|
28d590ddd2 | ||
|
|
a236d7a1b0 | ||
|
|
3d93ef7304 | ||
|
|
c5b1f750bc | ||
|
|
26f920c0da | ||
|
|
b982e0a541 |
+50
@@ -0,0 +1,50 @@
|
|||||||
|
# Dotcom Arcade: notes for agents
|
||||||
|
|
||||||
|
Dotcom Arcade is a heavily modified fork of
|
||||||
|
[flashpoint-web](https://github.com/darkraise/flashpoint-web) by darkraise
|
||||||
|
(GPL-3.0), with game streaming added under `streaming/`. Coding standards and
|
||||||
|
architecture notes are in `CLAUDE.md`; feature and deployment docs are in
|
||||||
|
`docs/`.
|
||||||
|
|
||||||
|
## Upstream
|
||||||
|
|
||||||
|
- **Upstream** is https://github.com/darkraise/flashpoint-web, branch `master`.
|
||||||
|
It's read-only from here: don't push to it or open pull requests there unless
|
||||||
|
asked.
|
||||||
|
- **Most of upstream's code is still used:** the backend, the frontend's data
|
||||||
|
layer, the player, and the account and admin pages. Fixes to these should come
|
||||||
|
in from upstream rather than being rewritten here.
|
||||||
|
- **To see what upstream has changed** since it was last merged, and how each
|
||||||
|
touched file stands here, run `scripts/upstream-status.sh`.
|
||||||
|
- **To pull changes in**, follow `docs/08-development/upstream-sync.md`. In
|
||||||
|
short:
|
||||||
|
- merge upstream's `master` on a branch (don't cherry-pick);
|
||||||
|
- keep files removed here removed;
|
||||||
|
- keep the Dotcom Arcade branding;
|
||||||
|
- keep upstream's migration file names;
|
||||||
|
- have the pull request merged with a merge commit, never squashed, so the
|
||||||
|
next sync starts from the right place.
|
||||||
|
- **Parts that are only ours:**
|
||||||
|
- `streaming/`;
|
||||||
|
- `scripts/`;
|
||||||
|
- `frontend/src/components/portal/`;
|
||||||
|
- `views/GamePage.tsx`;
|
||||||
|
- `lib/saves/flashSaves.ts`;
|
||||||
|
- the discovery backend;
|
||||||
|
- `.gitea/`;
|
||||||
|
- migrations upstream doesn't have.
|
||||||
|
|
||||||
|
## GitHub mirror
|
||||||
|
|
||||||
|
`mirror/` publishes `main` to a read-only GitHub mirror from the operator's
|
||||||
|
host, never from a session; see `docs/08-development/github-mirror.md`. GitHub
|
||||||
|
runs `.github/workflows`, the forge `.gitea/workflows`; keep the two image
|
||||||
|
workflows in step.
|
||||||
|
|
||||||
|
## CI
|
||||||
|
|
||||||
|
Workflows are in `.gitea/workflows`. Jobs that run docker builds use the
|
||||||
|
`dejima-privileged` runner label; anything else uses `dejima-confined`. The
|
||||||
|
privileged job image has no Docker client, so install `docker-ce-cli` first.
|
||||||
|
Never hardcode the forge's host name: read it at run time, or take it from the
|
||||||
|
`REGISTRY_HOST` repository variable.
|
||||||
+1
-1
@@ -13,7 +13,7 @@ JWT_SECRET=replace-with-a-secure-random-string
|
|||||||
# Built from source by default. To pull published images instead, set the
|
# Built from source by default. To pull published images instead, set the
|
||||||
# registry path they live under (ending in /), then:
|
# registry path they live under (ending in /), then:
|
||||||
# docker compose pull && docker compose up -d
|
# docker compose pull && docker compose up -d
|
||||||
# IMAGE_PREFIX=registry.example.com/owner/
|
# IMAGE_PREFIX=ghcr.io/branderson/
|
||||||
# IMAGE_TAG=latest
|
# IMAGE_TAG=latest
|
||||||
# "Latest release" API URL to check for updates (GitHub or Gitea); off when unset
|
# "Latest release" API URL to check for updates (GitHub or Gitea); off when unset
|
||||||
# APP_UPDATE_URL=https://git.example.com/api/v1/repos/owner/dotcom-arcade/releases/latest
|
# APP_UPDATE_URL=https://git.example.com/api/v1/repos/owner/dotcom-arcade/releases/latest
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ name: Images
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
tags: ['v*.*.*']
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
# Jobs that run docker builds use dejima-privileged; anything else should use
|
# Jobs that run docker builds use dejima-privileged; anything else should use
|
||||||
@@ -59,6 +58,12 @@ jobs:
|
|||||||
echo "host=$host" >> "$GITHUB_OUTPUT"
|
echo "host=$host" >> "$GITHUB_OUTPUT"
|
||||||
echo "owner=${GITHUB_REPOSITORY_OWNER,,}" >> "$GITHUB_OUTPUT"
|
echo "owner=${GITHUB_REPOSITORY_OWNER,,}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# Shown in Settings, since images carry no version number
|
||||||
|
- name: Name the version
|
||||||
|
id: version
|
||||||
|
shell: bash
|
||||||
|
run: echo "sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- uses: docker/setup-buildx-action@v3
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- uses: docker/login-action@v3
|
- uses: docker/login-action@v3
|
||||||
@@ -71,13 +76,13 @@ jobs:
|
|||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: ${{ steps.registry.outputs.host }}/${{ steps.registry.outputs.owner }}/${{ matrix.image }}
|
images: ${{ steps.registry.outputs.host }}/${{ steps.registry.outputs.owner }}/${{ matrix.image }}
|
||||||
tags: |
|
tags: type=raw,value=latest
|
||||||
type=semver,pattern={{version}}
|
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
|
||||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
|
||||||
type=ref,event=branch
|
|
||||||
|
|
||||||
- uses: docker/build-push-action@v6
|
# GHCR sometimes answers the streamer's base image pull with 429 Too Many
|
||||||
|
# Requests; it clears within minutes, so retry once.
|
||||||
|
- id: build
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
continue-on-error: true
|
||||||
with:
|
with:
|
||||||
context: ${{ matrix.context }}
|
context: ${{ matrix.context }}
|
||||||
push: true
|
push: true
|
||||||
@@ -85,5 +90,22 @@ jobs:
|
|||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
build-args: |
|
build-args: |
|
||||||
VERSION=${{ steps.meta.outputs.version }}
|
VERSION=${{ steps.version.outputs.sha }}
|
||||||
|
provenance: false
|
||||||
|
|
||||||
|
- name: Wait before retrying
|
||||||
|
if: steps.build.outcome == 'failure'
|
||||||
|
run: sleep 120
|
||||||
|
|
||||||
|
- name: Retry the build
|
||||||
|
if: steps.build.outcome == 'failure'
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: ${{ matrix.context }}
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
platforms: linux/amd64
|
||||||
|
build-args: |
|
||||||
|
VERSION=${{ steps.version.outputs.sha }}
|
||||||
provenance: false
|
provenance: false
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# Builds the app, session manager and streamer images on the GitHub mirror and
|
||||||
|
# pushes them to GitHub's container registry, under the repository's owner.
|
||||||
|
# The forge runs .gitea/workflows/images.yml instead; Gitea ignores this
|
||||||
|
# directory while .gitea/workflows exists.
|
||||||
|
name: Images
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: ${{ matrix.name }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- name: app
|
||||||
|
image: dotcom-arcade
|
||||||
|
context: .
|
||||||
|
- name: session-manager
|
||||||
|
image: dotcom-arcade-session-manager
|
||||||
|
context: streaming/session-manager
|
||||||
|
- name: streamer
|
||||||
|
image: dotcom-arcade-streamer
|
||||||
|
context: streaming/streamer
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Lowercase the owner
|
||||||
|
id: owner
|
||||||
|
shell: bash
|
||||||
|
run: echo "name=${GITHUB_REPOSITORY_OWNER,,}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# Shown in Settings, since images carry no version number
|
||||||
|
- name: Name the version
|
||||||
|
id: version
|
||||||
|
shell: bash
|
||||||
|
run: echo "sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ghcr.io/${{ steps.owner.outputs.name }}/${{ matrix.image }}
|
||||||
|
tags: type=raw,value=latest
|
||||||
|
|
||||||
|
# GHCR sometimes answers the streamer's base image pull with 429 Too Many
|
||||||
|
# Requests; it clears within minutes, so retry once.
|
||||||
|
- id: build
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
continue-on-error: true
|
||||||
|
with:
|
||||||
|
context: ${{ matrix.context }}
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
platforms: linux/amd64
|
||||||
|
build-args: |
|
||||||
|
VERSION=${{ steps.version.outputs.sha }}
|
||||||
|
provenance: false
|
||||||
|
|
||||||
|
- name: Wait before retrying
|
||||||
|
if: steps.build.outcome == 'failure'
|
||||||
|
run: sleep 120
|
||||||
|
|
||||||
|
- name: Retry the build
|
||||||
|
if: steps.build.outcome == 'failure'
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: ${{ matrix.context }}
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
platforms: linux/amd64
|
||||||
|
build-args: |
|
||||||
|
VERSION=${{ steps.version.outputs.sha }}
|
||||||
|
provenance: false
|
||||||
+15
-105
@@ -1,44 +1,11 @@
|
|||||||
# Contributing to Dotcom Arcade
|
# Contributing to Dotcom Arcade
|
||||||
|
|
||||||
Thanks for your interest in contributing! We welcome bug fixes, features, documentation improvements, and questions.
|
Dotcom Arcade is developed on a private forge and published to GitHub as a
|
||||||
|
read-only mirror, so issues and pull requests aren't accepted there. You're
|
||||||
|
welcome to fork it under the [GPL-3.0](LICENSE).
|
||||||
|
|
||||||
## What is Dotcom Arcade?
|
It's built on [flashpoint-web](https://github.com/darkraise/flashpoint-web).
|
||||||
|
Fixes to code the two still share may belong upstream.
|
||||||
A self-hosted web application for browsing and playing games from [Flashpoint Archive](https://flashpointarchive.org/).
|
|
||||||
|
|
||||||
**Tech Stack:** Node.js 20+, Express, React 18, TypeScript, SQLite, TanStack Query, Tailwind CSS
|
|
||||||
|
|
||||||
## Table of Contents
|
|
||||||
|
|
||||||
- [Where to Start](#where-to-start)
|
|
||||||
- [Getting Started](#getting-started)
|
|
||||||
- [Development Setup](#development-setup)
|
|
||||||
- [Running Tests](#running-tests)
|
|
||||||
- [Making Changes](#making-changes)
|
|
||||||
- [Submitting a Pull Request](#submitting-a-pull-request)
|
|
||||||
- [Code Style](#code-style)
|
|
||||||
- [Reporting Bugs](#reporting-bugs)
|
|
||||||
- [License](#license)
|
|
||||||
|
|
||||||
## Where to Start
|
|
||||||
|
|
||||||
- Browse issues labeled [`good first issue`](../../labels/good%20first%20issue) or [`help wanted`](../../labels/help%20wanted)
|
|
||||||
- Check the [project documentation](docs/) for architecture and patterns
|
|
||||||
|
|
||||||
## Getting Started
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Fork the repository on GitHub, then clone your fork
|
|
||||||
git clone https://github.com/YOUR_USERNAME/flashpoint-web.git
|
|
||||||
cd flashpoint-web
|
|
||||||
|
|
||||||
# Add upstream remote
|
|
||||||
git remote add upstream https://github.com/ORIGINAL_OWNER/flashpoint-web.git
|
|
||||||
|
|
||||||
# Keep your fork synced
|
|
||||||
git fetch upstream
|
|
||||||
git rebase upstream/master
|
|
||||||
```
|
|
||||||
|
|
||||||
## Development Setup
|
## Development Setup
|
||||||
|
|
||||||
@@ -47,6 +14,7 @@ git rebase upstream/master
|
|||||||
```
|
```
|
||||||
backend/ # Express REST API + game content serving (:3100)
|
backend/ # Express REST API + game content serving (:3100)
|
||||||
frontend/ # React + Vite application (:5173)
|
frontend/ # React + Vite application (:5173)
|
||||||
|
streaming/ # Session manager and streamer for games browsers can't run
|
||||||
docs/ # Documentation
|
docs/ # Documentation
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -69,6 +37,7 @@ The app (UI and API) runs on `http://localhost:80`.
|
|||||||
### Option 2: Manual Setup (Recommended for Development)
|
### Option 2: Manual Setup (Recommended for Development)
|
||||||
|
|
||||||
**Prerequisites:**
|
**Prerequisites:**
|
||||||
|
|
||||||
- Node.js 20+
|
- Node.js 20+
|
||||||
- Flashpoint Archive installed locally
|
- Flashpoint Archive installed locally
|
||||||
|
|
||||||
@@ -99,80 +68,21 @@ cd backend && npm test -- --coverage
|
|||||||
cd frontend && npm test
|
cd frontend && npm test
|
||||||
```
|
```
|
||||||
|
|
||||||
## Making Changes
|
|
||||||
|
|
||||||
### Branch Naming
|
|
||||||
|
|
||||||
```
|
|
||||||
feat/add-playlist-export
|
|
||||||
fix/game-search-pagination
|
|
||||||
refactor/auth-service
|
|
||||||
docs/update-api-reference
|
|
||||||
```
|
|
||||||
|
|
||||||
### Commit Messages
|
|
||||||
|
|
||||||
Follow [Conventional Commits](https://www.conventionalcommits.org/):
|
|
||||||
|
|
||||||
```
|
|
||||||
<type>(<scope>): <description>
|
|
||||||
|
|
||||||
feat(games): add advanced search filters
|
|
||||||
fix(auth): resolve token refresh race condition
|
|
||||||
docs: update deployment guide
|
|
||||||
refactor(api): simplify error handling
|
|
||||||
```
|
|
||||||
|
|
||||||
**Types:** `feat`, `fix`, `docs`, `refactor`, `test`, `chore`, `perf`
|
|
||||||
|
|
||||||
### Workflow
|
|
||||||
|
|
||||||
1. Create a branch from `master`
|
|
||||||
2. Make your changes
|
|
||||||
3. Run `npm run typecheck` and `npm test`
|
|
||||||
4. Update documentation if needed
|
|
||||||
5. Submit a pull request
|
|
||||||
|
|
||||||
## Submitting a Pull Request
|
|
||||||
|
|
||||||
### Before Submitting
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git fetch upstream
|
|
||||||
git rebase upstream/master
|
|
||||||
npm run typecheck
|
|
||||||
npm run format
|
|
||||||
npm test
|
|
||||||
```
|
|
||||||
|
|
||||||
### PR Guidelines
|
|
||||||
|
|
||||||
- Keep PRs focused and reasonably sized
|
|
||||||
- Reference related issues (`Fixes #123`)
|
|
||||||
- Include a clear description of changes
|
|
||||||
- Update documentation for new features
|
|
||||||
|
|
||||||
## Code Style
|
## Code Style
|
||||||
|
|
||||||
See [CLAUDE.md](CLAUDE.md) for detailed coding standards. Key points:
|
See [CLAUDE.md](CLAUDE.md) for detailed coding standards. Key points:
|
||||||
|
|
||||||
- **TypeScript:** No `any` types, use `unknown` or proper interfaces
|
- **TypeScript:** No `any` types, use `unknown` or proper interfaces
|
||||||
- **Backend:** Wrap async handlers with `asyncHandler()`, static routes before parameterized
|
- **Backend:** Wrap async handlers with `asyncHandler()`, static routes before
|
||||||
- **Frontend:** Use `@/` imports, all API calls through `lib/api.ts`, use theme tokens
|
parameterized
|
||||||
|
- **Frontend:** Use `@/` imports, all API calls through `lib/api.ts`, use theme
|
||||||
|
tokens
|
||||||
- **General:** Validate inputs, handle errors properly, clean up resources
|
- **General:** Validate inputs, handle errors properly, clean up resources
|
||||||
|
|
||||||
Run `npm run format` before committing.
|
Run `npm run format` before committing. Commit messages follow
|
||||||
|
[Conventional Commits](https://www.conventionalcommits.org/).
|
||||||
## Reporting Bugs
|
|
||||||
|
|
||||||
Before reporting, search [existing issues](../../issues) to avoid duplicates.
|
|
||||||
|
|
||||||
Include:
|
|
||||||
- Steps to reproduce
|
|
||||||
- Expected vs actual behavior
|
|
||||||
- Environment details (OS, Node version, browser)
|
|
||||||
- Screenshots or logs if applicable
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
This project is licensed under [GPL-3.0](LICENSE). By contributing, you agree that your contributions will be licensed under the same license.
|
This project is licensed under [GPL-3.0](LICENSE). By contributing, you agree
|
||||||
|
that your contributions will be licensed under the same license.
|
||||||
|
|||||||
@@ -135,6 +135,9 @@ PGID=1000
|
|||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
To pull prebuilt images instead of building them, see
|
||||||
|
[prebuilt images](docs/09-deployment/docker-deployment.md#prebuilt-images).
|
||||||
|
|
||||||
Open `http://<server>/` and create the admin account. On first start the app
|
Open `http://<server>/` and create the admin account. On first start the app
|
||||||
indexes the collection, which takes a few minutes on a full Ultimate install.
|
indexes the collection, which takes a few minutes on a full Ultimate install.
|
||||||
Under **Settings → App** you can set the site name and tagline.
|
Under **Settings → App** you can set the site name and tagline.
|
||||||
@@ -192,6 +195,11 @@ and development. Useful starting points:
|
|||||||
- [Ruffle](https://ruffle.rs/), for running Flash in modern browsers.
|
- [Ruffle](https://ruffle.rs/), for running Flash in modern browsers.
|
||||||
- [Selkies](https://github.com/selkies-project/selkies), which streams the
|
- [Selkies](https://github.com/selkies-project/selkies), which streams the
|
||||||
games.
|
games.
|
||||||
|
- [Texgriddy](https://github.com/Weestuarty/texgriddy-es-de) by Weestuarty, an
|
||||||
|
ES-DE theme that inspired the site's look. The look was recreated from scratch
|
||||||
|
in CSS; none of Texgriddy's files, images or fonts are used.
|
||||||
|
- [Fredoka](https://fontsource.org/fonts/fredoka) (SIL Open Font License), the
|
||||||
|
display font.
|
||||||
|
|
||||||
## Legal
|
## Legal
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ the Dotcom Arcade application.
|
|||||||
5. **[Testing Guide](./testing-guide.md)** - Testing approach and best practices
|
5. **[Testing Guide](./testing-guide.md)** - Testing approach and best practices
|
||||||
6. **[Debugging Guide](./debugging.md)** - Debugging tips and troubleshooting
|
6. **[Debugging Guide](./debugging.md)** - Debugging tips and troubleshooting
|
||||||
7. **[Common Pitfalls](./common-pitfalls.md)** - Known issues and solutions
|
7. **[Common Pitfalls](./common-pitfalls.md)** - Known issues and solutions
|
||||||
|
8. **[Upstream Sync](./upstream-sync.md)** - Merging changes from flashpoint-web
|
||||||
|
9. **[GitHub Mirror](./github-mirror.md)** - Publishing to GitHub and GHCR
|
||||||
|
10. **[Demo Data](./demo-data.md)** - Made-up games for development and screenshots
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Demo data
|
||||||
|
|
||||||
|
`scripts/demo/build-demo-data.mjs` builds a small Flashpoint install of made-up
|
||||||
|
games, for developing without a 2.3 TB download and for taking the README's
|
||||||
|
screenshots. Everything in it is invented:
|
||||||
|
|
||||||
|
- about 50 games with fake titles, studios and descriptions;
|
||||||
|
- generated tile art and screenshots;
|
||||||
|
- four curated playlists;
|
||||||
|
- **Pixel Penguin Panic**, a tiny HTML5 game written for the demo, which is
|
||||||
|
the only one that plays.
|
||||||
|
|
||||||
|
The database uses Flashpoint 14's schema (`scripts/demo/flashpoint-schema.sql`,
|
||||||
|
from [FPA-Rust](https://github.com/FlashpointProject/FPA-Rust), MIT).
|
||||||
|
|
||||||
|
## Using it
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run install:all
|
||||||
|
node scripts/demo/build-demo-data.mjs /tmp/demo-flashpoint
|
||||||
|
```
|
||||||
|
|
||||||
|
Then point the app at it, with `FLASHPOINT_PATH=/tmp/demo-flashpoint` in
|
||||||
|
`backend/.env` for development, or `FLASHPOINT_HOST_PATH` in `.env` for Docker.
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
The README's screenshots in `docs/images` come from the demo data, so they show
|
||||||
|
no real games. To retake them after a UI change:
|
||||||
|
|
||||||
|
1. Build the frontend (`npm run build`), then run the app on the demo data with
|
||||||
|
a fresh user database, serving the built frontend (`SERVE_FRONTEND=true`).
|
||||||
|
2. Install Playwright: `npm install --no-save playwright && npx playwright install chromium`.
|
||||||
|
3. Run `node scripts/demo/take-screenshots.mjs http://localhost:3100`. Restart
|
||||||
|
the app with a fresh user database before running it again: repeated runs
|
||||||
|
trip the login rate limit.
|
||||||
|
|
||||||
|
The script creates the first account, plays a few games for "Jump back in",
|
||||||
|
fixes the browser's date so the Game of the Day is always the same, moves the
|
||||||
|
Time Machine to the week the demo fills, and writes `home.png`, `game.png` and
|
||||||
|
`playing.png`.
|
||||||
|
|
||||||
|
Each game's tile and screenshot is a small scene drawn for its genre (a road
|
||||||
|
for driving games, a block grid for puzzles, and so on), with layouts varied by
|
||||||
|
the game's tags and seeded by its title, so every build draws the same art.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# GitHub mirror
|
||||||
|
|
||||||
|
Dotcom Arcade is developed on a private forge. `mirror/github-mirror.sh`
|
||||||
|
publishes its `main` and the tags on `main` to
|
||||||
|
[github.com/branderson/dotcom-arcade](https://github.com/branderson/dotcom-arcade),
|
||||||
|
hourly, from a host that can push to GitHub. Issues and pull requests are off
|
||||||
|
there. Actions stays on, since it builds the images that `ghcr.io/branderson/`
|
||||||
|
serves (see [publishing images](../09-deployment/docker-deployment.md#publishing-images)).
|
||||||
|
|
||||||
|
The mirror's `main` is the forge's plus one commit, which adds a read-only notice
|
||||||
|
under the README's title. Every forge commit is in the mirror unchanged, and
|
||||||
|
each sync fast-forwards. If the README loses its `# ` title, the sync stops
|
||||||
|
instead of publishing without the notice.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Set up the timer as described at the top of
|
||||||
|
`mirror/dotcom-arcade-github-mirror.service`. Give it a deploy key with write
|
||||||
|
access to the GitHub repository.
|
||||||
|
2. Run `github-mirror.sh --dry-run` to build what a sync would publish without
|
||||||
|
pushing it.
|
||||||
|
3. Once the first sync has pushed, run `github-mirror.sh --settings` (needs
|
||||||
|
`gh`). It turns off issues, pull requests, projects, wiki and discussions.
|
||||||
|
4. The first image build creates its packages as private. In each package's
|
||||||
|
settings on GitHub, set its visibility to public.
|
||||||
|
|
||||||
|
The timer runs a copy of the script, not the checkout's. Reinstall it after
|
||||||
|
reviewing a change to `mirror/`.
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# Syncing with upstream flashpoint-web
|
||||||
|
|
||||||
|
Dotcom Arcade is a heavily modified fork of
|
||||||
|
[flashpoint-web](https://github.com/darkraise/flashpoint-web) by darkraise
|
||||||
|
(branch `master`). Most of upstream's code is still used here: the backend, the
|
||||||
|
frontend's data layer, the player, and the account and admin pages. Upstream's
|
||||||
|
fixes and features to those parts should keep coming in, so the two projects
|
||||||
|
don't drift apart.
|
||||||
|
|
||||||
|
## What's whose
|
||||||
|
|
||||||
|
Comparing this repository with the last upstream commit merged into it:
|
||||||
|
|
||||||
|
- **Unchanged here:** most of the backend, the API client, hooks, stores and
|
||||||
|
types, the player, the account and admin views, and most docs. Upstream
|
||||||
|
changes to these merge as they are.
|
||||||
|
- **Changed here:** files upstream also owns that this project has modified.
|
||||||
|
The main ones:
|
||||||
|
- `GameService` (mature filter, release ranges, shuffle, related games);
|
||||||
|
- `server.ts` and `config.ts` (stream proxy, update check);
|
||||||
|
- `App.tsx` (portal routes);
|
||||||
|
- `GamePlayer` (streaming, saves);
|
||||||
|
- the browse layout and filters;
|
||||||
|
- the rebrand across names and docs.
|
||||||
|
|
||||||
|
Upstream changes to these need merging by hand.
|
||||||
|
- **Removed here:**
|
||||||
|
- the Flash and HTML5 sections;
|
||||||
|
- the GitHub stars endpoint;
|
||||||
|
- the old homepage pieces;
|
||||||
|
- the dev compose file;
|
||||||
|
- upstream's CI workflow and design notes.
|
||||||
|
|
||||||
|
These stay removed.
|
||||||
|
- **Only here:**
|
||||||
|
- `streaming/`;
|
||||||
|
- `scripts/`;
|
||||||
|
- `frontend/src/components/portal/`;
|
||||||
|
- `views/GamePage.tsx`;
|
||||||
|
- `lib/saves/flashSaves.ts`;
|
||||||
|
- `.gitea/`;
|
||||||
|
- the discovery backend;
|
||||||
|
- migrations upstream doesn't have.
|
||||||
|
|
||||||
|
Upstream never touches these, but a change elsewhere can still need matching
|
||||||
|
work here.
|
||||||
|
|
||||||
|
`scripts/upstream-status.sh` lists upstream's new commits and puts every file
|
||||||
|
they touch in one of these groups, so this list doesn't need keeping up to date
|
||||||
|
by hand.
|
||||||
|
|
||||||
|
## When
|
||||||
|
|
||||||
|
Run `scripts/upstream-status.sh` when upstream publishes a release, and at
|
||||||
|
least monthly. Sync when it shows new commits.
|
||||||
|
|
||||||
|
## How
|
||||||
|
|
||||||
|
1. On a branch from `main`, merge upstream:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
scripts/upstream-status.sh
|
||||||
|
git checkout -b upstream-sync-$(date +%Y-%m-%d) origin/main
|
||||||
|
git merge refs/remotes/upstream/master
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Resolve conflicts by group:
|
||||||
|
- **Changed here:** keep this project's behavior and take upstream's fix or
|
||||||
|
feature with it. Where they truly conflict, prefer this project's
|
||||||
|
behavior, and note what was left out in the pull request.
|
||||||
|
- **Removed here:** keep the file deleted
|
||||||
|
(`git rm <file>`). If upstream's change matters elsewhere, such as a bug
|
||||||
|
fix in shared logic, port that part to where the code lives now.
|
||||||
|
- **New upstream:** take it, unless it belongs to something removed here.
|
||||||
|
New UI usually lands in the old application layout. Decide whether it
|
||||||
|
belongs in the portal, and if so, follow up with a portal version.
|
||||||
|
- **Branding:** keep "Dotcom Arcade", our image and container names, and our
|
||||||
|
update check (`APP_UPDATE_URL`); don't take back upstream's.
|
||||||
|
3. Migrations: keep upstream's migration files under their own names, even
|
||||||
|
when a number clashes with ours. The migrations table records full names,
|
||||||
|
so `009_app_tagline` and an upstream `009_something` both run, in name
|
||||||
|
order. Renaming an upstream migration would run it a second time on
|
||||||
|
databases that already applied it. Check that ours and theirs don't depend
|
||||||
|
on each other's order.
|
||||||
|
4. Check the result:
|
||||||
|
- backend and frontend typecheck, tests and build;
|
||||||
|
- the session manager's tests if `streaming/` was affected;
|
||||||
|
- `docker compose up -d --build` with and without the `streaming` profile;
|
||||||
|
- the docs, for anything the merge made inaccurate.
|
||||||
|
5. Open a pull request listing upstream's commits and how each conflict was
|
||||||
|
resolved. **Merge it with a merge commit, not squash or rebase.** The merge
|
||||||
|
records how far upstream has been synced, so the next run of
|
||||||
|
`scripts/upstream-status.sh` starts from the right place.
|
||||||
|
|
||||||
|
## Sending changes upstream
|
||||||
|
|
||||||
|
Fixes made here to code upstream still shares, such as backend bugs, may be
|
||||||
|
worth offering upstream. Do that separately, from a branch based on upstream's
|
||||||
|
`master`, and only when the project's maintainer decides to.
|
||||||
@@ -28,22 +28,30 @@ All three are built from this repository by `docker compose up -d --build`.
|
|||||||
|
|
||||||
### Prebuilt images
|
### Prebuilt images
|
||||||
|
|
||||||
To pull published images instead of building, set `IMAGE_PREFIX` in `.env` to
|
Images are published to GitHub's container registry. To pull them instead of
|
||||||
the registry path they live under, ending in `/`, and optionally `IMAGE_TAG`:
|
building, set `IMAGE_PREFIX` in `.env`, ending in `/`. There's one tag,
|
||||||
|
`latest`, built from the tip of `main`.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
IMAGE_PREFIX=registry.example.com/owner/
|
IMAGE_PREFIX=ghcr.io/branderson/
|
||||||
IMAGE_TAG=latest
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Then run `docker compose pull && docker compose up -d`.
|
Then run `docker compose pull && docker compose up -d`. Images from another
|
||||||
|
registry work the same way, with its path as the prefix.
|
||||||
|
|
||||||
### Publishing images
|
### Publishing images
|
||||||
|
|
||||||
|
Two workflows build the same images with the same tags:
|
||||||
|
|
||||||
|
- `.github/workflows/images.yml` runs on the
|
||||||
|
[GitHub mirror](../08-development/github-mirror.md) and pushes to
|
||||||
|
`ghcr.io/<owner>/` with the workflow's own token. It needs no setup.
|
||||||
|
- `.gitea/workflows/images.yml` runs on the forge. Gitea ignores
|
||||||
|
`.github/workflows` while `.gitea/workflows` exists.
|
||||||
|
|
||||||
`.gitea/workflows/images.yml` builds all three images on Gitea Actions and
|
`.gitea/workflows/images.yml` builds all three images on Gitea Actions and
|
||||||
pushes them to the Gitea instance's container registry, under the repository's
|
pushes them to the Gitea instance's container registry, under the repository's
|
||||||
owner. Pushes to `main` publish a `main` tag; `v1.2.3` tags publish `1.2.3`,
|
owner. Each push to `main` publishes `latest`. Its jobs run docker builds, so they need a runner labelled
|
||||||
`1.2` and `latest`. Its jobs run docker builds, so they need a runner labelled
|
|
||||||
`dejima-privileged`; jobs that don't build images should use `dejima-confined`.
|
`dejima-privileged`; jobs that don't build images should use `dejima-confined`.
|
||||||
It also needs a repository secret, `REGISTRY_TOKEN`: an access token with
|
It also needs a repository secret, `REGISTRY_TOKEN`: an access token with
|
||||||
package write access, belonging to the repository's owner, whom the workflow
|
package write access, belonging to the repository's owner, whom the workflow
|
||||||
|
|||||||
@@ -33,4 +33,9 @@ What changes:
|
|||||||
- **Streaming config.** Streaming is now part of the same compose file. Replace
|
- **Streaming config.** Streaming is now part of the same compose file. Replace
|
||||||
the separate `API_TOKEN` and `STREAMING_API_URL` with `STREAMING_API_TOKEN`
|
the separate `API_TOKEN` and `STREAMING_API_URL` with `STREAMING_API_TOKEN`
|
||||||
and `COMPOSE_PROFILES=streaming` (see [Streaming](../10-features/13-streaming.md)).
|
and `COMPOSE_PROFILES=streaming` (see [Streaming](../10-features/13-streaming.md)).
|
||||||
The `/stream/` proxy route is unchanged.
|
- **No separate `/stream/` route.** The app now proxies streams to the session
|
||||||
|
manager itself, and the session manager no longer publishes port 8081.
|
||||||
|
Remove any `location /stream/` block from your reverse proxy, or streams
|
||||||
|
will time out with a 504. Move its WebSocket and timeout settings to the
|
||||||
|
location that serves the app (see
|
||||||
|
[Reverse proxy](../10-features/13-streaming.md#reverse-proxy)).
|
||||||
|
|||||||
@@ -30,9 +30,15 @@ tokens are applied to `<body>` while the portal is mounted, which takes
|
|||||||
precedence over the app theme on `<html>` and also reaches dialogs and menus
|
precedence over the app theme on `<html>` and also reaches dialogs and menus
|
||||||
rendered into the body.
|
rendered into the body.
|
||||||
|
|
||||||
One scheme ships, **Chilled** (slate background, sage boxes). Adding a scheme
|
One scheme ships, **Slate** (slate background, sage boxes). Adding a scheme
|
||||||
means adding an entry to `PORTAL_SCHEMES`; no component changes are needed.
|
means adding an entry to `PORTAL_SCHEMES`; no component changes are needed.
|
||||||
|
|
||||||
|
The portal's look is inspired by
|
||||||
|
[Texgriddy](https://github.com/Weestuarty/texgriddy-es-de), an ES-DE theme by
|
||||||
|
Weestuarty: the framed page, glossy tiles with a metallic ring, outlined info
|
||||||
|
boxes, capsule buttons and the slate and sage palette. It was recreated from
|
||||||
|
scratch in CSS; none of Texgriddy's files, images or fonts are used.
|
||||||
|
|
||||||
Display text uses Fredoka (OFL, bundled via `@fontsource-variable/fredoka`);
|
Display text uses Fredoka (OFL, bundled via `@fontsource-variable/fredoka`);
|
||||||
body text uses Verdana.
|
body text uses Verdana.
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,45 @@ The app works it out by watching instead: whatever changes while a game is
|
|||||||
playing belongs to that game, and it remembers that for next time. The app's own
|
playing belongs to that game, and it remembers that for next time. The app's own
|
||||||
storage is never included.
|
storage is never included.
|
||||||
|
|
||||||
|
## Ruffle and the original Flash Player
|
||||||
|
|
||||||
|
A Flash game's saves carry over when switching between Ruffle and the original
|
||||||
|
Flash Player (streamed). Both store SharedObjects in the same `.sol` format,
|
||||||
|
under different names:
|
||||||
|
|
||||||
|
- Ruffle keeps them in localStorage, keyed by the site's host and the path it
|
||||||
|
loaded the SWF from.
|
||||||
|
- Flash Player keeps them as files under `Flash_Player/#SharedObjects/` in the
|
||||||
|
streamed game's saves, keyed by the SWF's own host and path.
|
||||||
|
|
||||||
|
Starting the original Flash Player adds the game's Ruffle saves to the files the
|
||||||
|
stream starts with. While it plays, its saves are copied back into Ruffle's
|
||||||
|
storage as they change. Saves made with a `localPath` of `/` are shared by
|
||||||
|
every game in Ruffle, so only those seen changing while this game ran are
|
||||||
|
carried over.
|
||||||
|
|
||||||
|
Ruffle's copy of a save is kept up to date in both directions, so it's the one
|
||||||
|
the stream starts with:
|
||||||
|
|
||||||
|
- Before Ruffle starts a game, it's given any of the stream's saves it doesn't
|
||||||
|
have yet, such as progress made only in the stream. The game then loads that
|
||||||
|
progress instead of starting a fresh save, which would otherwise replace the
|
||||||
|
stream's progress on the next switch. Saves Ruffle already has are left alone.
|
||||||
|
- While the stream plays, the saves it changes are copied into Ruffle.
|
||||||
|
|
||||||
|
Some games save under a different path in each player. A SWF that a wrapper
|
||||||
|
(such as MochiAds) loads from memory saves under its loader's path plus
|
||||||
|
`[[DYNAMIC]]/<n>`, and a wrapper may do that in Ruffle but not in the
|
||||||
|
standalone Flash Player. Bloons, for example, keeps its progress under
|
||||||
|
`bloons.swf/[[DYNAMIC]]/1/` in Ruffle and under `bloons.swf/` in Flash Player.
|
||||||
|
Saves that differ only by that step are treated as one save:
|
||||||
|
|
||||||
|
- When the stream starts, a save is written to every path the stream has for
|
||||||
|
it. If Ruffle has both a dynamic and a plain copy, the dynamic one is used.
|
||||||
|
- A save the stream changes is copied to every key Ruffle has for it.
|
||||||
|
|
||||||
|
A stream save replaced this way is still in the account's backups.
|
||||||
|
|
||||||
## Sync
|
## Sync
|
||||||
|
|
||||||
Before a game starts, the app compares the browser's save with the account's
|
Before a game starts, the app compares the browser's save with the account's
|
||||||
|
|||||||
@@ -125,6 +125,9 @@ for Flashpoint Ultimate.
|
|||||||
- [Testing Guide](08-development/testing-guide.md) - Testing approach
|
- [Testing Guide](08-development/testing-guide.md) - Testing approach
|
||||||
- [Debugging](08-development/debugging.md) - Debugging tips
|
- [Debugging](08-development/debugging.md) - Debugging tips
|
||||||
- [Common Pitfalls](08-development/common-pitfalls.md) - Known issues
|
- [Common Pitfalls](08-development/common-pitfalls.md) - Known issues
|
||||||
|
- [Upstream Sync](08-development/upstream-sync.md) - Merging changes from flashpoint-web
|
||||||
|
- [GitHub Mirror](08-development/github-mirror.md) - Publishing to GitHub and GHCR
|
||||||
|
- [Demo Data](08-development/demo-data.md) - Made-up games for development and screenshots
|
||||||
|
|
||||||
### 09. Deployment
|
### 09. Deployment
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 571 KiB After Width: | Height: | Size: 294 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 800 KiB After Width: | Height: | Size: 472 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 730 KiB After Width: | Height: | Size: 248 KiB |
@@ -17,14 +17,15 @@ const flashGame = {
|
|||||||
describe('GamePlayer player choice', () => {
|
describe('GamePlayer player choice', () => {
|
||||||
it('switches a streamable Flash game between Ruffle and the original Flash Player', async () => {
|
it('switches a streamable Flash game between Ruffle and the original Flash Player', async () => {
|
||||||
render(<GamePlayer {...flashGame} streamable />);
|
render(<GamePlayer {...flashGame} streamable />);
|
||||||
expect(screen.getByText('ruffle')).toBeInTheDocument();
|
// Ruffle starts once any streamed saves have been handed to it
|
||||||
|
expect(await screen.findByText('ruffle')).toBeInTheDocument();
|
||||||
|
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Use original Flash Player' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Use original Flash Player' }));
|
||||||
expect(screen.getByText('stream')).toBeInTheDocument();
|
expect(screen.getByText('stream')).toBeInTheDocument();
|
||||||
expect(screen.queryByText('ruffle')).not.toBeInTheDocument();
|
expect(screen.queryByText('ruffle')).not.toBeInTheDocument();
|
||||||
|
|
||||||
await userEvent.click(screen.getByRole('button', { name: 'Use Ruffle' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Use Ruffle' }));
|
||||||
expect(screen.getByText('ruffle')).toBeInTheDocument();
|
expect(await screen.findByText('ruffle')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('offers no choice when the game cannot be streamed', () => {
|
it('offers no choice when the game cannot be streamed', () => {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { RufflePlayer } from './RufflePlayer';
|
|||||||
import { StreamPlayer } from './StreamPlayer';
|
import { StreamPlayer } from './StreamPlayer';
|
||||||
import { SavesDialog } from './SavesDialog';
|
import { SavesDialog } from './SavesDialog';
|
||||||
import { useGameSaveSync } from '@/hooks/useGameSaveSync';
|
import { useGameSaveSync } from '@/hooks/useGameSaveSync';
|
||||||
|
import { addStreamSavesToRuffle } from '@/lib/saves/flashSaves';
|
||||||
import { useAuthStore } from '@/store/auth';
|
import { useAuthStore } from '@/store/auth';
|
||||||
import { PlayerErrorBoundary } from './PlayerErrorBoundary';
|
import { PlayerErrorBoundary } from './PlayerErrorBoundary';
|
||||||
import { PlatformIcon } from '@/components/ui/platform-icon';
|
import { PlatformIcon } from '@/components/ui/platform-icon';
|
||||||
@@ -137,6 +138,41 @@ function GamePlayerInternal({
|
|||||||
}
|
}
|
||||||
}, [contentUrl, platform]);
|
}, [contentUrl, platform]);
|
||||||
|
|
||||||
|
// Determine Flash content type: direct SWF vs HTML wrapper
|
||||||
|
// HTML wrappers (index.html, .html, .htm) need iframe, not Ruffle
|
||||||
|
const flashContentType = useMemo(() => {
|
||||||
|
if (platform !== 'Flash' || !contentUrl) return null;
|
||||||
|
|
||||||
|
const urlPath = contentUrl.toLowerCase();
|
||||||
|
const isSwfUrl = urlPath.includes('.swf');
|
||||||
|
const isHtmlWrapper =
|
||||||
|
urlPath.includes('.html') || urlPath.includes('.htm') || urlPath.endsWith('/');
|
||||||
|
|
||||||
|
if (isSwfUrl && !isHtmlWrapper) {
|
||||||
|
return 'swf' as const;
|
||||||
|
}
|
||||||
|
return 'html-wrapper' as const;
|
||||||
|
}, [platform, contentUrl]);
|
||||||
|
|
||||||
|
// Before Ruffle starts, it gets any saves the streamed Flash Player made that
|
||||||
|
// it doesn't have yet. Otherwise a game played only streamed would start from
|
||||||
|
// scratch here, and that fresh save would replace its progress on the next switch.
|
||||||
|
const ruffleSwf = gameId && flashContentType === 'swf' && contentUrl ? contentUrl : null;
|
||||||
|
const [ruffleSavesEpoch, setRuffleSavesEpoch] = useState<number | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!saveSync.ready || !ruffleSwf || !gameId) return;
|
||||||
|
let cancelled = false;
|
||||||
|
addStreamSavesToRuffle(gameId, ruffleSwf)
|
||||||
|
.catch((error) => logger.error('[GamePlayer] Failed to copy stream saves to Ruffle:', error))
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setRuffleSavesEpoch(saveSync.epoch);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [saveSync.ready, saveSync.epoch, ruffleSwf, gameId]);
|
||||||
|
const ruffleSavesReady = !ruffleSwf || ruffleSavesEpoch === saveSync.epoch;
|
||||||
|
|
||||||
const canSwitchPlayer = canPlayInBrowser && streamable && Boolean(gameId);
|
const canSwitchPlayer = canPlayInBrowser && streamable && Boolean(gameId);
|
||||||
const isStreamed = streamable && Boolean(gameId) && (!canPlayInBrowser || preferStream);
|
const isStreamed = streamable && Boolean(gameId) && (!canPlayInBrowser || preferStream);
|
||||||
|
|
||||||
@@ -178,22 +214,6 @@ function GamePlayerInternal({
|
|||||||
|
|
||||||
const isAspectVideo = height === 'aspect-video';
|
const isAspectVideo = height === 'aspect-video';
|
||||||
|
|
||||||
// Determine Flash content type: direct SWF vs HTML wrapper
|
|
||||||
// HTML wrappers (index.html, .html, .htm) need iframe, not Ruffle
|
|
||||||
const flashContentType = useMemo(() => {
|
|
||||||
if (platform !== 'Flash' || !contentUrl) return null;
|
|
||||||
|
|
||||||
const urlPath = contentUrl.toLowerCase();
|
|
||||||
const isSwfUrl = urlPath.includes('.swf');
|
|
||||||
const isHtmlWrapper =
|
|
||||||
urlPath.includes('.html') || urlPath.includes('.htm') || urlPath.endsWith('/');
|
|
||||||
|
|
||||||
if (isSwfUrl && !isHtmlWrapper) {
|
|
||||||
return 'swf' as const;
|
|
||||||
}
|
|
||||||
return 'html-wrapper' as const;
|
|
||||||
}, [platform, contentUrl]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${containerClasses} ${isAspectVideo ? 'flex flex-col' : ''}`}>
|
<div className={`${containerClasses} ${isAspectVideo ? 'flex flex-col' : ''}`}>
|
||||||
{showControls && (isFullscreen || !renderControls) ? (
|
{showControls && (isFullscreen || !renderControls) ? (
|
||||||
@@ -327,7 +347,7 @@ function GamePlayerInternal({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{!saveSync.ready ? (
|
{!saveSync.ready || (!isStreamed && !ruffleSavesReady) ? (
|
||||||
<div className="flex items-center justify-center h-full gap-3 text-muted-foreground">
|
<div className="flex items-center justify-center h-full gap-3 text-muted-foreground">
|
||||||
<Loader2 className="animate-spin" aria-hidden="true" />
|
<Loader2 className="animate-spin" aria-hidden="true" />
|
||||||
<span>Loading your save...</span>
|
<span>Loading your save...</span>
|
||||||
@@ -336,7 +356,11 @@ function GamePlayerInternal({
|
|||||||
<Fragment key={saveSync.epoch}>
|
<Fragment key={saveSync.epoch}>
|
||||||
{/* Other platforms - stream from the server */}
|
{/* Other platforms - stream from the server */}
|
||||||
{isStreamed && gameId ? (
|
{isStreamed && gameId ? (
|
||||||
<StreamPlayer gameId={gameId} title={title} />
|
<StreamPlayer
|
||||||
|
gameId={gameId}
|
||||||
|
title={title}
|
||||||
|
swfUrl={flashContentType === 'swf' ? contentUrl : undefined}
|
||||||
|
/>
|
||||||
) : /* Flash SWF - use Ruffle emulator */ flashContentType === 'swf' && contentUrl ? (
|
) : /* Flash SWF - use Ruffle emulator */ flashContentType === 'swf' && contentUrl ? (
|
||||||
<RufflePlayer
|
<RufflePlayer
|
||||||
swfUrl={contentUrl}
|
swfUrl={contentUrl}
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ import { useEffect, useState } from 'react';
|
|||||||
import { AlertCircle, Loader2 } from 'lucide-react';
|
import { AlertCircle, Loader2 } from 'lucide-react';
|
||||||
import { gamesApi } from '@/lib/api/games';
|
import { gamesApi } from '@/lib/api/games';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
|
import { flashSaveContext, mirrorToRuffle, withRuffleSaves } from '@/lib/saves/flashSaves';
|
||||||
import { loadSaves, storeSaves } from '@/lib/streamSaves';
|
import { loadSaves, storeSaves } from '@/lib/streamSaves';
|
||||||
import { getErrorMessage } from '@/types/api-error';
|
import { getErrorMessage } from '@/types/api-error';
|
||||||
|
|
||||||
export interface StreamPlayerProps {
|
export interface StreamPlayerProps {
|
||||||
gameId: string;
|
gameId: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
/** For a Flash game Ruffle can also play: its SWF, so saves carry between the two */
|
||||||
|
swfUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type StreamState =
|
type StreamState =
|
||||||
@@ -19,16 +22,18 @@ type StreamState =
|
|||||||
* Plays a game that can't run in a browser by streaming it from a server-side
|
* Plays a game that can't run in a browser by streaming it from a server-side
|
||||||
* container. Saves are kept in this browser and synced with the container.
|
* container. Saves are kept in this browser and synced with the container.
|
||||||
*/
|
*/
|
||||||
export function StreamPlayer({ gameId, title }: StreamPlayerProps) {
|
export function StreamPlayer({ gameId, title, swfUrl }: StreamPlayerProps) {
|
||||||
const [state, setState] = useState<StreamState>({ status: 'starting' });
|
const [state, setState] = useState<StreamState>({ status: 'starting' });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let events: EventSource | undefined;
|
let events: EventSource | undefined;
|
||||||
|
const flash = swfUrl ? flashSaveContext(swfUrl, window.location.hostname) : null;
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
const session = await gamesApi.createStreamSession(gameId);
|
const session = await gamesApi.createStreamSession(gameId);
|
||||||
const files = await loadSaves(gameId);
|
const stored = await loadSaves(gameId);
|
||||||
|
const files = flash ? await withRuffleSaves(gameId, stored, flash) : stored;
|
||||||
const response = await fetch(`${session.path}_fp/start`, {
|
const response = await fetch(`${session.path}_fp/start`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -40,10 +45,14 @@ export function StreamPlayer({ gameId, title }: StreamPlayerProps) {
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
events = new EventSource(`${session.path}_fp/saves`);
|
events = new EventSource(`${session.path}_fp/saves`);
|
||||||
|
let previous = files;
|
||||||
events.onmessage = (event) => {
|
events.onmessage = (event) => {
|
||||||
storeSaves(gameId, JSON.parse(event.data).files).catch((error) =>
|
const { files: latest } = JSON.parse(event.data);
|
||||||
|
storeSaves(gameId, latest).catch((error) =>
|
||||||
logger.error('[StreamPlayer] Failed to store saves:', error)
|
logger.error('[StreamPlayer] Failed to store saves:', error)
|
||||||
);
|
);
|
||||||
|
if (flash) mirrorToRuffle(latest, previous, flash);
|
||||||
|
previous = latest;
|
||||||
};
|
};
|
||||||
events.addEventListener('end', () => events?.close());
|
events.addEventListener('end', () => events?.close());
|
||||||
setState({ status: 'running', path: session.path });
|
setState({ status: 'running', path: session.path });
|
||||||
@@ -56,7 +65,7 @@ export function StreamPlayer({ gameId, title }: StreamPlayerProps) {
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
events?.close();
|
events?.close();
|
||||||
};
|
};
|
||||||
}, [gameId]);
|
}, [gameId, swfUrl]);
|
||||||
|
|
||||||
if (state.status === 'error') {
|
if (state.status === 'error') {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function PortalLayout() {
|
|||||||
const searchRef = useRef<HTMLInputElement>(null);
|
const searchRef = useRef<HTMLInputElement>(null);
|
||||||
const [rolling, setRolling] = useState(false);
|
const [rolling, setRolling] = useState(false);
|
||||||
|
|
||||||
useEffect(() => applyPortalScheme('chilled'), []);
|
useEffect(() => applyPortalScheme('slate'), []);
|
||||||
useMatureSync();
|
useMatureSync();
|
||||||
|
|
||||||
const playRandom = async () => {
|
const playRandom = async () => {
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ export interface PortalScheme {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const PORTAL_SCHEMES = {
|
export const PORTAL_SCHEMES = {
|
||||||
chilled: {
|
slate: {
|
||||||
label: 'Chilled',
|
label: 'Slate',
|
||||||
tokens: {
|
tokens: {
|
||||||
'--p-bg': '#4c6878',
|
'--p-bg': '#4c6878',
|
||||||
'--p-bg-mix': '#2f4553',
|
'--p-bg-mix': '#2f4553',
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
addStreamSavesToRuffle,
|
||||||
|
flashSaveContext,
|
||||||
|
flashToRuffle,
|
||||||
|
mirrorToRuffle,
|
||||||
|
ruffleToFlash,
|
||||||
|
withRuffleSaves,
|
||||||
|
} from './flashSaves';
|
||||||
|
|
||||||
|
const syncState = vi.hoisted(() => ({ keys: [] as string[] }));
|
||||||
|
const streamSaves = vi.hoisted(() => ({ files: {} as Record<string, string> }));
|
||||||
|
vi.mock('@/lib/streamSaves', () => ({ loadSaves: async () => streamSaves.files }));
|
||||||
|
vi.mock('./gameSaves', () => ({
|
||||||
|
loadSyncState: async (gameId: string) => ({
|
||||||
|
gameId,
|
||||||
|
keys: syncState.keys,
|
||||||
|
databases: [],
|
||||||
|
syncedHash: null,
|
||||||
|
savedAt: null,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Written by Ruffle for SharedObject.getLocal('pathsave') and getLocal('rootsave', '/')
|
||||||
|
// in a SWF loaded from /game-proxy/http://savetest.example/dir/test.swf
|
||||||
|
const PATH_KEY = 'arcade.example.com/game-proxy/http://savetest.example/dir/test.swf/pathsave';
|
||||||
|
const ROOT_KEY = 'arcade.example.com//rootsave';
|
||||||
|
const PATH_SOL = 'AL8AAAApVENTTwAEAAAAAAAIcGF0aHNhdmUAAAAAAAVzY29yZQBARQAAAAAAAAA=';
|
||||||
|
const ROOT_SOL = 'AL8AAAApVENTTwAEAAAAAAAIcm9vdHNhdmUAAAAAAAVzY29yZQBARQAAAAAAAAA=';
|
||||||
|
const PATH_FILE = 'Flash_Player/#SharedObjects/ABCD1234/savetest.example/dir/test.swf/pathsave.sol';
|
||||||
|
const ROOT_FILE = 'Flash_Player/#SharedObjects/ABCD1234/savetest.example/rootsave.sol';
|
||||||
|
|
||||||
|
const context = flashSaveContext(
|
||||||
|
'/game-proxy/http://savetest.example/dir/test.swf',
|
||||||
|
'arcade.example.com'
|
||||||
|
);
|
||||||
|
if (!context) throw new Error('expected a context');
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
syncState.keys = [];
|
||||||
|
streamSaves.files = {};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('flash save names', () => {
|
||||||
|
it('reads the naming context from a proxied SWF URL only', () => {
|
||||||
|
expect(context).toEqual({
|
||||||
|
siteHost: 'arcade.example.com',
|
||||||
|
proxyRoot: '/game-proxy/http://savetest.example',
|
||||||
|
swfHost: 'savetest.example',
|
||||||
|
});
|
||||||
|
expect(flashSaveContext('/game-zip/abc/test.swf', 'arcade.example.com')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps Ruffle's saves to Flash Player files, root saves only when the game made them", () => {
|
||||||
|
const entries = { [PATH_KEY]: PATH_SOL, [ROOT_KEY]: ROOT_SOL, 'other-game': 'not a save' };
|
||||||
|
const folder = { 'Flash_Player/#SharedObjects/ABCD1234/other.example/x.sol': 'AL8x' };
|
||||||
|
expect(ruffleToFlash(entries, context, new Set(), folder)).toEqual({
|
||||||
|
[PATH_FILE]: PATH_SOL,
|
||||||
|
});
|
||||||
|
expect(ruffleToFlash(entries, context, new Set([ROOT_KEY]), folder)).toEqual({
|
||||||
|
[PATH_FILE]: PATH_SOL,
|
||||||
|
[ROOT_FILE]: ROOT_SOL,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps Flash Player files back to Ruffle's keys, ignoring other sites and files", () => {
|
||||||
|
const files = {
|
||||||
|
[PATH_FILE]: PATH_SOL,
|
||||||
|
[ROOT_FILE]: ROOT_SOL,
|
||||||
|
'Flash_Player/#SharedObjects/ABCD1234/elsewhere.example/x.sol': PATH_SOL,
|
||||||
|
'Prefs/data.txt': 'eA==',
|
||||||
|
};
|
||||||
|
expect(flashToRuffle(files, context)).toEqual({ [PATH_KEY]: PATH_SOL, [ROOT_KEY]: ROOT_SOL });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('carrying saves between players', () => {
|
||||||
|
it("starts the stream with Ruffle's saves, in the stream's own folder", async () => {
|
||||||
|
localStorage.setItem(PATH_KEY, PATH_SOL);
|
||||||
|
const files = await withRuffleSaves('game', { 'Prefs/x': 'eA==' }, context);
|
||||||
|
expect(files).toEqual({
|
||||||
|
'Prefs/x': 'eA==',
|
||||||
|
'Flash_Player/#SharedObjects/DOTCOMAR/savetest.example/dir/test.swf/pathsave.sol': PATH_SOL,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replaces the stream's copy of a save with Ruffle's, in the stream's folder", async () => {
|
||||||
|
localStorage.setItem(PATH_KEY, PATH_SOL);
|
||||||
|
const streamCopy = { [PATH_FILE]: 'AL8stalecopy', [ROOT_FILE]: ROOT_SOL };
|
||||||
|
expect(await withRuffleSaves('game', streamCopy, context)).toEqual({
|
||||||
|
[PATH_FILE]: PATH_SOL,
|
||||||
|
[ROOT_FILE]: ROOT_SOL,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("copies the saves the stream changed into Ruffle's storage, and only those", () => {
|
||||||
|
localStorage.setItem(ROOT_KEY, 'AL8ruffleprogress');
|
||||||
|
mirrorToRuffle(
|
||||||
|
{ [PATH_FILE]: PATH_SOL, [ROOT_FILE]: ROOT_SOL },
|
||||||
|
{ [ROOT_FILE]: ROOT_SOL },
|
||||||
|
context
|
||||||
|
);
|
||||||
|
expect(localStorage.getItem(PATH_KEY)).toBe(PATH_SOL);
|
||||||
|
expect(localStorage.getItem(ROOT_KEY)).toBe('AL8ruffleprogress');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives Ruffle the stream-only saves before it starts, keeping its own', async () => {
|
||||||
|
window.history.replaceState(null, '', '/browse/game');
|
||||||
|
const host = window.location.hostname;
|
||||||
|
streamSaves.files = { [PATH_FILE]: PATH_SOL, [ROOT_FILE]: ROOT_SOL };
|
||||||
|
localStorage.setItem(`${host}//rootsave`, 'AL8ruffleprogress');
|
||||||
|
|
||||||
|
await addStreamSavesToRuffle('game', '/game-proxy/http://savetest.example/dir/test.swf');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
localStorage.getItem(`${host}/game-proxy/http://savetest.example/dir/test.swf/pathsave`)
|
||||||
|
).toBe(PATH_SOL);
|
||||||
|
expect(localStorage.getItem(`${host}//rootsave`)).toBe('AL8ruffleprogress');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bloons runs inside a MochiAds wrapper: in Ruffle the game saves under
|
||||||
|
// [[DYNAMIC]]/1/, in the standalone Flash Player under the plain path
|
||||||
|
describe('saves a wrapper keeps under [[DYNAMIC]] in one player only', () => {
|
||||||
|
const bloons = flashSaveContext(
|
||||||
|
'/game-proxy/http://games.nkstatic.com/Games/gameswfs/mochi_migration/bloons.swf',
|
||||||
|
'arcade.example.com'
|
||||||
|
);
|
||||||
|
if (!bloons) throw new Error('expected a context');
|
||||||
|
const base =
|
||||||
|
'arcade.example.com/game-proxy/http://games.nkstatic.com/Games/gameswfs/mochi_migration/bloons.swf';
|
||||||
|
const files =
|
||||||
|
'Flash_Player/#SharedObjects/AQUKE2KN/games.nkstatic.com/Games/gameswfs/mochi_migration/bloons.swf';
|
||||||
|
// A fresh profile, and one with levels cleared (shortened)
|
||||||
|
const FRESH =
|
||||||
|
'AL8AAABEVENTTwAEAAAAAAANYmxvb25zUHJvZmlsZQAAAAAABmxldmVsMQIABWNsZWFyAAAKbGV2ZWxiZXN0MQAAAAAAAAAAAAA=';
|
||||||
|
const PROGRESS = 'AL8AAAJJVENTTwAEAAAAAAANYmxvb25zUHJvZmlsZQ';
|
||||||
|
|
||||||
|
it("gives Flash Player's plain path Ruffle's dynamic progress", () => {
|
||||||
|
const ruffle = {
|
||||||
|
[`${base}/bloonsProfile`]: FRESH,
|
||||||
|
[`${base}/[[DYNAMIC]]/1/bloonsProfile`]: PROGRESS,
|
||||||
|
};
|
||||||
|
const stream = { [`${files}/bloonsProfile.sol`]: FRESH };
|
||||||
|
expect(ruffleToFlash(ruffle, bloons, new Set(), stream)).toEqual({
|
||||||
|
[`${files}/bloonsProfile.sol`]: PROGRESS,
|
||||||
|
[`${files}/[[DYNAMIC]]/1/bloonsProfile.sol`]: PROGRESS,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("brings progress made in Flash Player back to Ruffle's dynamic path", () => {
|
||||||
|
localStorage.setItem(`${base}/bloonsProfile`, FRESH);
|
||||||
|
localStorage.setItem(`${base}/[[DYNAMIC]]/1/bloonsProfile`, FRESH);
|
||||||
|
mirrorToRuffle(
|
||||||
|
{ [`${files}/bloonsProfile.sol`]: PROGRESS },
|
||||||
|
{ [`${files}/bloonsProfile.sol`]: FRESH },
|
||||||
|
bloons
|
||||||
|
);
|
||||||
|
expect(localStorage.getItem(`${base}/[[DYNAMIC]]/1/bloonsProfile`)).toBe(PROGRESS);
|
||||||
|
expect(localStorage.getItem(`${base}/bloonsProfile`)).toBe(PROGRESS);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import { loadSyncState } from './gameSaves';
|
||||||
|
import { loadSaves, type SaveFiles } from '@/lib/streamSaves';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ruffle and the original Flash Player (streamed) store SharedObjects in the
|
||||||
|
* same .sol format, under different names:
|
||||||
|
*
|
||||||
|
* - Ruffle, in localStorage, base64-encoded, keyed by the site's host and the
|
||||||
|
* path it loaded the SWF from: `<site>/game-proxy/http://<host>/<path>/<name>`,
|
||||||
|
* or `<site>//<name>` for a localPath of "/".
|
||||||
|
* - Flash Player, as files in the streamed game's saves directory:
|
||||||
|
* `Flash_Player/#SharedObjects/<folder>/<host>/<path>/<name>.sol`.
|
||||||
|
*
|
||||||
|
* A SWF loaded from memory, as by a MochiAds wrapper, saves under its loader's
|
||||||
|
* path plus `[[DYNAMIC]]/<n>`, and a wrapper may do that in one player and not
|
||||||
|
* the other: Bloons saves at `bloons.swf/[[DYNAMIC]]/1/` in Ruffle and at
|
||||||
|
* `bloons.swf/` in Flash Player. Saves differing only by that step are
|
||||||
|
* treated as one save.
|
||||||
|
*
|
||||||
|
* These helpers translate between the two so a game's saves follow it from one
|
||||||
|
* player to the other.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SHARED_OBJECTS = 'Flash_Player/#SharedObjects/';
|
||||||
|
/** Flash Player names this folder randomly when it creates it; an existing one is reused */
|
||||||
|
const DEFAULT_FOLDER = 'DOTCOMAR';
|
||||||
|
/** Every .sol file starts with the bytes 00 BF 00, which base64-encode to "AL8" */
|
||||||
|
const SOL_PREFIX = 'AL8';
|
||||||
|
|
||||||
|
export interface FlashSaveContext {
|
||||||
|
/** Host Ruffle keys saves by: the site's own */
|
||||||
|
siteHost: string;
|
||||||
|
/** Path Ruffle loads the game's SWF host under, e.g. /game-proxy/http://example.com */
|
||||||
|
proxyRoot: string;
|
||||||
|
/** Host Flash Player keys saves by: the SWF's own */
|
||||||
|
swfHost: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The naming context for a SWF played through the game proxy, or null for any other URL. */
|
||||||
|
export function flashSaveContext(swfUrl: string, siteHost: string): FlashSaveContext | null {
|
||||||
|
const match = swfUrl.match(/^(\/game-proxy\/https?:\/\/([^/]+))\//);
|
||||||
|
return match ? { siteHost, proxyRoot: match[1], swfHost: match[2] } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Ruffle key's save as a Flash Player path, `<host>/<path>/<name>.sol`, or
|
||||||
|
* null if it isn't this game's. Root-path saves are shared by every game, so
|
||||||
|
* only those in rootKeys count, unless rootKeys is null.
|
||||||
|
*/
|
||||||
|
function ruffleKeyToPath(
|
||||||
|
key: string,
|
||||||
|
context: FlashSaveContext,
|
||||||
|
rootKeys: ReadonlySet<string> | null
|
||||||
|
): string | null {
|
||||||
|
if (!key.startsWith(`${context.siteHost}/`)) return null;
|
||||||
|
const rest = key.slice(context.siteHost.length);
|
||||||
|
const slash = rest.lastIndexOf('/');
|
||||||
|
const localPath = rest.slice(0, slash) || '/';
|
||||||
|
const name = rest.slice(slash + 1);
|
||||||
|
if (localPath === '/') {
|
||||||
|
return rootKeys === null || rootKeys.has(key) ? `${context.swfHost}/${name}.sol` : null;
|
||||||
|
}
|
||||||
|
if (localPath.startsWith(`${context.proxyRoot}/`)) {
|
||||||
|
return `${context.swfHost}${localPath.slice(context.proxyRoot.length)}/${name}.sol`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The Ruffle key for a Flash Player path, `<host>/<path>/<name>.sol`, or null for another host's. */
|
||||||
|
function pathToRuffleKey(path: string, context: FlashSaveContext): string | null {
|
||||||
|
const parts = path.split('/');
|
||||||
|
if (parts.length < 2 || parts[0] !== context.swfHost || !path.endsWith('.sol')) return null;
|
||||||
|
const name = parts[parts.length - 1].slice(0, -'.sol'.length);
|
||||||
|
const dirs = parts.slice(1, -1);
|
||||||
|
return dirs.length
|
||||||
|
? `${context.siteHost}${context.proxyRoot}/${dirs.join('/')}/${name}`
|
||||||
|
: `${context.siteHost}//${name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A save file's folder and path below it, or null for files that aren't SharedObjects. */
|
||||||
|
function splitSaveFile(file: string): { folder: string; path: string } | null {
|
||||||
|
if (!file.startsWith(SHARED_OBJECTS) || !file.endsWith('.sol')) return null;
|
||||||
|
const rest = file.slice(SHARED_OBJECTS.length);
|
||||||
|
const slash = rest.indexOf('/');
|
||||||
|
return slash > 0 ? { folder: rest.slice(0, slash), path: rest.slice(slash + 1) } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A save's path without its [[DYNAMIC]]/<n> steps, which one player may add and the other not. */
|
||||||
|
function saveIdentity(path: string): string {
|
||||||
|
return path.replace(/\/\[\[DYNAMIC\]\]\/\d+(?=\/)/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDynamic(path: string): boolean {
|
||||||
|
return saveIdentity(path) !== path;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sharedObjectsFolder(files: SaveFiles): string {
|
||||||
|
for (const file of Object.keys(files)) {
|
||||||
|
const split = splitSaveFile(file);
|
||||||
|
if (split) return split.folder;
|
||||||
|
}
|
||||||
|
return DEFAULT_FOLDER;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flash Player save files for a game's Ruffle SharedObjects. Each save is
|
||||||
|
* written to its own path and to every path the stream already has for it.
|
||||||
|
* Where Ruffle has a save under both a [[DYNAMIC]] path and the plain one, the
|
||||||
|
* dynamic copy is used: that's the game itself, the plain one its wrapper's.
|
||||||
|
*/
|
||||||
|
export function ruffleToFlash(
|
||||||
|
entries: Record<string, string>,
|
||||||
|
context: FlashSaveContext,
|
||||||
|
rootKeys: ReadonlySet<string>,
|
||||||
|
existing: SaveFiles = {}
|
||||||
|
): SaveFiles {
|
||||||
|
const folder = sharedObjectsFolder(existing);
|
||||||
|
const chosen = new Map<string, { value: string; dynamic: boolean }>();
|
||||||
|
const targets = new Map<string, Set<string>>();
|
||||||
|
const addTarget = (identity: string, file: string) =>
|
||||||
|
targets.set(identity, (targets.get(identity) ?? new Set()).add(file));
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(entries)) {
|
||||||
|
if (!value.startsWith(SOL_PREFIX)) continue;
|
||||||
|
const path = ruffleKeyToPath(key, context, rootKeys);
|
||||||
|
if (path === null) continue;
|
||||||
|
const identity = saveIdentity(path);
|
||||||
|
const current = chosen.get(identity);
|
||||||
|
if (!current || (isDynamic(path) && !current.dynamic)) {
|
||||||
|
chosen.set(identity, { value, dynamic: isDynamic(path) });
|
||||||
|
}
|
||||||
|
addTarget(identity, `${SHARED_OBJECTS}${folder}/${path}`);
|
||||||
|
}
|
||||||
|
for (const file of Object.keys(existing)) {
|
||||||
|
const split = splitSaveFile(file);
|
||||||
|
if (split && chosen.has(saveIdentity(split.path))) addTarget(saveIdentity(split.path), file);
|
||||||
|
}
|
||||||
|
|
||||||
|
const files: SaveFiles = {};
|
||||||
|
for (const [identity, { value }] of chosen) {
|
||||||
|
for (const file of targets.get(identity) ?? []) files[file] = value;
|
||||||
|
}
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ruffle localStorage entries for a game's Flash Player save files, path for path. */
|
||||||
|
export function flashToRuffle(files: SaveFiles, context: FlashSaveContext): Record<string, string> {
|
||||||
|
const entries: Record<string, string> = {};
|
||||||
|
for (const [file, value] of Object.entries(files)) {
|
||||||
|
const split = splitSaveFile(file);
|
||||||
|
const key = split && pathToRuffleKey(split.path, context);
|
||||||
|
if (key) entries[key] = value;
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readLocalStorage(): Record<string, string> {
|
||||||
|
const entries: Record<string, string> = {};
|
||||||
|
for (let i = 0; i < localStorage.length; i++) {
|
||||||
|
const key = localStorage.key(i);
|
||||||
|
const value = key === null ? null : localStorage.getItem(key);
|
||||||
|
if (key !== null && value !== null) entries[key] = value;
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The save files to start a streamed Flash game with: its stream saves, with
|
||||||
|
* its Ruffle saves on top. Ruffle is where a game is usually played, and the
|
||||||
|
* stream's own changes are copied back into Ruffle as they happen, so Ruffle's
|
||||||
|
* copy is the latest.
|
||||||
|
*/
|
||||||
|
export async function withRuffleSaves(
|
||||||
|
gameId: string,
|
||||||
|
files: SaveFiles,
|
||||||
|
context: FlashSaveContext
|
||||||
|
): Promise<SaveFiles> {
|
||||||
|
const { keys } = await loadSyncState(gameId);
|
||||||
|
return { ...files, ...ruffleToFlash(readLocalStorage(), context, new Set(keys), files) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies the saves a streamed game changed since `previous` into Ruffle's
|
||||||
|
* storage: to the save's own key and to every key Ruffle already has for it.
|
||||||
|
*/
|
||||||
|
export function mirrorToRuffle(
|
||||||
|
files: SaveFiles,
|
||||||
|
previous: SaveFiles,
|
||||||
|
context: FlashSaveContext
|
||||||
|
): void {
|
||||||
|
const ruffleKeys = Object.keys(readLocalStorage());
|
||||||
|
for (const [file, value] of Object.entries(files)) {
|
||||||
|
if (previous[file] === value) continue;
|
||||||
|
const split = splitSaveFile(file);
|
||||||
|
const key = split && pathToRuffleKey(split.path, context);
|
||||||
|
if (!split || !key) continue;
|
||||||
|
const identity = saveIdentity(split.path);
|
||||||
|
const aliases = ruffleKeys.filter((k) => {
|
||||||
|
const path = ruffleKeyToPath(k, context, null);
|
||||||
|
return path !== null && saveIdentity(path) === identity;
|
||||||
|
});
|
||||||
|
for (const target of new Set([key, ...aliases])) {
|
||||||
|
if (localStorage.getItem(target) !== value) localStorage.setItem(target, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Before Ruffle plays a game, gives it the streamed Flash Player's saves it
|
||||||
|
* doesn't have yet, so progress made only in the stream isn't started over.
|
||||||
|
* Saves Ruffle already has are left alone.
|
||||||
|
*/
|
||||||
|
export async function addStreamSavesToRuffle(gameId: string, swfUrl: string): Promise<void> {
|
||||||
|
const context = flashSaveContext(swfUrl, window.location.hostname);
|
||||||
|
if (!context) return;
|
||||||
|
for (const [key, value] of Object.entries(flashToRuffle(await loadSaves(gameId), context))) {
|
||||||
|
if (localStorage.getItem(key) === null) {
|
||||||
|
localStorage.setItem(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# User unit for the GitHub mirror, on a host that can push to GitHub. Never in
|
||||||
|
# a dejima session: a sandbox may push only to its own forge.
|
||||||
|
#
|
||||||
|
# install -m 755 mirror/github-mirror.sh ~/.local/bin/dotcom-arcade-github-mirror
|
||||||
|
# cp mirror/dotcom-arcade-github-mirror.service mirror/dotcom-arcade-github-mirror.timer ~/.config/systemd/user/
|
||||||
|
# systemctl --user daemon-reload
|
||||||
|
# systemctl --user enable --now dotcom-arcade-github-mirror.timer
|
||||||
|
#
|
||||||
|
# A copy, not a link into the checkout: the timer holds a key that can push to
|
||||||
|
# GitHub, and a session working in this checkout can edit the script.
|
||||||
|
# Reinstall it after reviewing an update.
|
||||||
|
#
|
||||||
|
# ~/.config/dotcom-arcade/github-mirror.env sets MIRROR_SOURCE, the forge
|
||||||
|
# clone URL, and whatever else mirror/github-mirror.sh documents. The GitHub
|
||||||
|
# key, a deploy key with write access to the repository, belongs behind an ssh
|
||||||
|
# Host alias named in MIRROR_TARGET, so it is not also offered to the forge:
|
||||||
|
#
|
||||||
|
# MIRROR_SOURCE=git@forge.example.com:owner/dotcom-arcade.git
|
||||||
|
# MIRROR_TARGET=git@github-dotcom-arcade:branderson/dotcom-arcade.git
|
||||||
|
[Unit]
|
||||||
|
Description=Mirror Dotcom Arcade's main and release tags to GitHub
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
EnvironmentFile=%h/.config/dotcom-arcade/github-mirror.env
|
||||||
|
ExecStart=%h/.local/bin/dotcom-arcade-github-mirror
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Mirror Dotcom Arcade's main and release tags to GitHub, hourly
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnCalendar=hourly
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
Executable
+133
@@ -0,0 +1,133 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Publishes main and its release tags to a read-only mirror on GitHub.
|
||||||
|
#
|
||||||
|
# MIRROR_SOURCE=<forge clone URL> mirror/github-mirror.sh sync
|
||||||
|
# ... mirror/github-mirror.sh --dry-run build and print, push nothing
|
||||||
|
# mirror/github-mirror.sh --settings configure the GitHub repo (needs gh)
|
||||||
|
# mirror/github-mirror.sh --transform DIR rewrite a checkout in place
|
||||||
|
#
|
||||||
|
# Runs on a host that can push to GitHub, never in a dejima session: a sandbox
|
||||||
|
# may push only to its own forge.
|
||||||
|
#
|
||||||
|
# The GitHub copy is not byte-for-byte. Its main is one extra commit on top of
|
||||||
|
# the forge's main, whose tree is the forge's with the rewrites in transform()
|
||||||
|
# applied, and whose parents are the forge commit and the previous mirror
|
||||||
|
# commit. So every forge SHA is in the mirror unchanged, and each sync is a
|
||||||
|
# fast-forward for anyone tracking it. Tags are pushed as they are, pointing at
|
||||||
|
# the untransformed forge commits.
|
||||||
|
#
|
||||||
|
# Environment:
|
||||||
|
# MIRROR_SOURCE forge clone URL (required to sync)
|
||||||
|
# MIRROR_REPO GitHub repository, default branderson/dotcom-arcade
|
||||||
|
# MIRROR_TARGET push URL, default git@github.com:$MIRROR_REPO.git
|
||||||
|
# MIRROR_STATE working bare repository, default ~/.cache/dotcom-arcade-github-mirror.git
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
MIRROR_REPO="${MIRROR_REPO:-branderson/dotcom-arcade}"
|
||||||
|
MIRROR_TARGET="${MIRROR_TARGET:-git@github.com:$MIRROR_REPO.git}"
|
||||||
|
MIRROR_STATE="${MIRROR_STATE:-${XDG_CACHE_HOME:-$HOME/.cache}/dotcom-arcade-github-mirror.git}"
|
||||||
|
|
||||||
|
say() { echo "github-mirror: $*"; }
|
||||||
|
die() { say "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
WORK=""
|
||||||
|
cleanup() {
|
||||||
|
[ -n "$WORK" ] || return 0
|
||||||
|
git -C "$MIRROR_STATE" worktree remove --force "$WORK" 2>/dev/null || true
|
||||||
|
rm -rf "$WORK"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Rewrites a checkout for GitHub. Each rewrite is checked, so a README that
|
||||||
|
# drifted away from what this expects stops the mirror instead of publishing
|
||||||
|
# the forge's version. Running it twice changes nothing.
|
||||||
|
transform() {
|
||||||
|
local readme="$1/README.md"
|
||||||
|
# perl -i would write a symlink's target back as the README, publishing it.
|
||||||
|
[ -f "$readme" ] && [ ! -L "$readme" ] || die "README.md is missing or a symlink in $1"
|
||||||
|
|
||||||
|
# The first thing a visitor to a mirror does is open an issue or a pull
|
||||||
|
# request. Under the title.
|
||||||
|
grep -q '^> \*\*This is a read-only mirror' "$readme" || perl -0pi -e '
|
||||||
|
s{^(# [^\n]*)\n}{$1\n\n> **This is a read-only mirror.** Dotcom Arcade is developed on a private\n> forge, and issues and pull requests are not accepted here.\n}m
|
||||||
|
' "$readme"
|
||||||
|
grep -q '^> \*\*This is a read-only mirror' "$readme" \
|
||||||
|
|| die "README.md: no title to put the mirror notice under"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Issues, pull requests, wiki and the like off; Actions stays on, since it
|
||||||
|
# publishes the images. Settings rather than content, so a sync does not
|
||||||
|
# reapply them; run once after creating the repository.
|
||||||
|
settings() {
|
||||||
|
command -v gh >/dev/null || die "--settings needs the gh CLI"
|
||||||
|
gh api -X PATCH "repos/$MIRROR_REPO" --silent \
|
||||||
|
-F has_issues=false -F has_pull_requests=false -F has_projects=false \
|
||||||
|
-F has_wiki=false -F has_discussions=false
|
||||||
|
}
|
||||||
|
|
||||||
|
sync() {
|
||||||
|
[ -n "${MIRROR_SOURCE:-}" ] || die "MIRROR_SOURCE is not set"
|
||||||
|
local git=(git -C "$MIRROR_STATE")
|
||||||
|
trap cleanup EXIT
|
||||||
|
[ -d "$MIRROR_STATE" ] || git init -q --bare "$MIRROR_STATE"
|
||||||
|
|
||||||
|
"${git[@]}" fetch -q --no-tags --prune "$MIRROR_SOURCE" \
|
||||||
|
'+refs/heads/main:refs/source/main' '+refs/tags/*:refs/source/tags/*'
|
||||||
|
local src prev=""
|
||||||
|
src="$("${git[@]}" rev-parse refs/source/main)"
|
||||||
|
|
||||||
|
# An absent main (exit 2) is a first run; anything else is a real failure.
|
||||||
|
local rc=0
|
||||||
|
git ls-remote -q --exit-code "$MIRROR_TARGET" refs/heads/main >/dev/null || rc=$?
|
||||||
|
if [ "$rc" -eq 0 ]; then
|
||||||
|
"${git[@]}" fetch -q --no-tags "$MIRROR_TARGET" '+refs/heads/main:refs/mirror/main'
|
||||||
|
prev="$("${git[@]}" rev-parse refs/mirror/main)"
|
||||||
|
elif [ "$rc" -ne 2 ]; then
|
||||||
|
die "cannot read $MIRROR_TARGET"
|
||||||
|
fi
|
||||||
|
|
||||||
|
WORK="$(mktemp -d)"
|
||||||
|
"${git[@]}" worktree add -q --detach "$WORK" "$src"
|
||||||
|
transform "$WORK"
|
||||||
|
git -C "$WORK" add -A
|
||||||
|
local tree
|
||||||
|
tree="$(git -C "$WORK" write-tree)"
|
||||||
|
|
||||||
|
if [ -n "$prev" ] && [ "$("${git[@]}" rev-parse "$prev^1")" = "$src" ] \
|
||||||
|
&& [ "$("${git[@]}" rev-parse "$prev^{tree}")" = "$tree" ]; then
|
||||||
|
say "already at ${src:0:12}"
|
||||||
|
else
|
||||||
|
# The forge commit's committer and date, so the same input always
|
||||||
|
# makes the same commit.
|
||||||
|
local name email date commit
|
||||||
|
IFS=$'\t' read -r name email date < <("${git[@]}" log -1 --format='%cn%x09%ce%x09%cI' "$src")
|
||||||
|
commit="$(GIT_AUTHOR_NAME="$name" GIT_AUTHOR_EMAIL="$email" GIT_AUTHOR_DATE="$date" \
|
||||||
|
GIT_COMMITTER_NAME="$name" GIT_COMMITTER_EMAIL="$email" GIT_COMMITTER_DATE="$date" \
|
||||||
|
"${git[@]}" commit-tree "$tree" -p "$src" ${prev:+-p "$prev"} \
|
||||||
|
-m "Mirror ${src:0:12} for GitHub" \
|
||||||
|
-m "README header added by mirror/github-mirror.sh.")"
|
||||||
|
"${git[@]}" update-ref refs/mirror/next "$commit"
|
||||||
|
[ -n "$DRY_RUN" ] || "${git[@]}" push -q "$MIRROR_TARGET" "$commit:refs/heads/main"
|
||||||
|
say "main is ${src:0:12} as ${commit:0:12}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Only tags on main: one on another branch would publish unreleased work.
|
||||||
|
local ref
|
||||||
|
local refspecs=()
|
||||||
|
while IFS= read -r ref; do
|
||||||
|
if "${git[@]}" merge-base --is-ancestor "$ref" "$src"; then
|
||||||
|
refspecs+=("$ref:refs/tags/${ref#refs/source/tags/}")
|
||||||
|
say "tag ${ref#refs/source/tags/}"
|
||||||
|
fi
|
||||||
|
done < <("${git[@]}" for-each-ref --format='%(refname)' refs/source/tags/)
|
||||||
|
[ "${#refspecs[@]}" -eq 0 ] || [ -n "$DRY_RUN" ] \
|
||||||
|
|| "${git[@]}" push -q "$MIRROR_TARGET" "${refspecs[@]}"
|
||||||
|
}
|
||||||
|
|
||||||
|
DRY_RUN=""
|
||||||
|
case "${1:-}" in
|
||||||
|
"") sync ;;
|
||||||
|
--dry-run) DRY_RUN=1; sync ;;
|
||||||
|
--settings) settings ;;
|
||||||
|
--transform) [ -n "${2:-}" ] || die "--transform needs a directory"; transform "$2" ;;
|
||||||
|
*) sed -n '4,7p' "$0" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,246 @@
|
|||||||
|
-- Flashpoint 14's database schema, the result of the migrations in
|
||||||
|
-- https://github.com/FlashpointProject/FPA-Rust (crates/flashpoint-archive),
|
||||||
|
-- Copyright (c) 2024 Flashpoint Archive, MIT License.
|
||||||
|
|
||||||
|
CREATE TABLE "additional_app" (
|
||||||
|
"id" varchar NOT NULL,
|
||||||
|
"applicationPath" varchar NOT NULL,
|
||||||
|
"autoRunBefore" boolean NOT NULL,
|
||||||
|
"launchCommand" varchar NOT NULL,
|
||||||
|
"name" varchar NOT NULL COLLATE NOCASE,
|
||||||
|
"waitForExit" boolean NOT NULL,
|
||||||
|
"parentGameId" varchar,
|
||||||
|
PRIMARY KEY("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "custom_id_order" (
|
||||||
|
"id" VARCHAR NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE ext_data (
|
||||||
|
"extId" varchar NOT NULL,
|
||||||
|
"gameId" varchar NOT NULL,
|
||||||
|
"data" jsonb,
|
||||||
|
PRIMARY KEY(extId, gameId)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "game" (
|
||||||
|
"id" varchar NOT NULL,
|
||||||
|
"parentGameId" varchar,
|
||||||
|
"title" varchar NOT NULL,
|
||||||
|
"alternateTitles" varchar NOT NULL,
|
||||||
|
"series" varchar NOT NULL,
|
||||||
|
"developer" varchar NOT NULL,
|
||||||
|
"publisher" varchar NOT NULL,
|
||||||
|
"dateAdded" datetime NOT NULL,
|
||||||
|
"dateModified" datetime NOT NULL DEFAULT (datetime('now')),
|
||||||
|
"broken" boolean NOT NULL,
|
||||||
|
"extreme" boolean NOT NULL,
|
||||||
|
"playMode" varchar NOT NULL,
|
||||||
|
"status" varchar NOT NULL,
|
||||||
|
"notes" varchar NOT NULL,
|
||||||
|
"source" varchar NOT NULL,
|
||||||
|
"applicationPath" varchar NOT NULL,
|
||||||
|
"launchCommand" varchar NOT NULL,
|
||||||
|
"releaseDate" varchar NOT NULL,
|
||||||
|
"version" varchar NOT NULL,
|
||||||
|
"originalDescription" varchar NOT NULL,
|
||||||
|
"language" varchar NOT NULL,
|
||||||
|
"library" varchar NOT NULL,
|
||||||
|
"orderTitle" varchar NOT NULL,
|
||||||
|
"activeDataId" integer,
|
||||||
|
"activeDataOnDisk" boolean NOT NULL DEFAULT (0),
|
||||||
|
"tagsStr" varchar NOT NULL DEFAULT ('') COLLATE NOCASE,
|
||||||
|
"platformsStr" varchar,
|
||||||
|
"platformId" integer,
|
||||||
|
"lastPlayed" datetime,
|
||||||
|
"playtime" integer DEFAULT 0,
|
||||||
|
"playCounter" integer DEFAULT 0,
|
||||||
|
"archiveState" integer DEFAULT 2,
|
||||||
|
"activeGameConfigId" integer,
|
||||||
|
"activeGameConfigOwner" varchar COLLATE NOCASE, "platformName" varchar COLLATE NOCASE, "ruffleSupport" varchar NOT NULL DEFAULT '', "logoPath" varchar, "screenshotPath" varchar, "owner" varchar,
|
||||||
|
PRIMARY KEY("id"),
|
||||||
|
CONSTRAINT "FK_45a9180069d42ac8231ff11acd0" FOREIGN KEY("parentGameId") REFERENCES "game"("id") ON DELETE NO ACTION ON UPDATE NO ACTION
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "game_config" (
|
||||||
|
"id" integer NOT NULL,
|
||||||
|
"gameId" varchar NOT NULL COLLATE NOCASE,
|
||||||
|
"name" varchar NOT NULL COLLATE NOCASE,
|
||||||
|
"owner" varchar NOT NULL COLLATE NOCASE,
|
||||||
|
"middleware" varchar,
|
||||||
|
PRIMARY KEY("id" AUTOINCREMENT)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "game_data" (
|
||||||
|
"id" integer NOT NULL,
|
||||||
|
"gameId" varchar,
|
||||||
|
"title" varchar NOT NULL,
|
||||||
|
"dateAdded" datetime NOT NULL,
|
||||||
|
"sha256" varchar NOT NULL,
|
||||||
|
"crc32" integer NOT NULL,
|
||||||
|
"presentOnDisk" boolean NOT NULL DEFAULT (0),
|
||||||
|
"path" varchar,
|
||||||
|
"size" integer NOT NULL,
|
||||||
|
"parameters" varchar,
|
||||||
|
"applicationPath" varchar,
|
||||||
|
"launchCommand" varchar,
|
||||||
|
PRIMARY KEY("id" AUTOINCREMENT),
|
||||||
|
CONSTRAINT "UQ_gameid_dateadded" UNIQUE("gameId", "dateAdded"),
|
||||||
|
CONSTRAINT "FK_gamedata_gameid" FOREIGN KEY("gameId") REFERENCES "game"("id") ON DELETE NO ACTION ON UPDATE NO ACTION
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "game_platforms_platform" (
|
||||||
|
"gameId" varchar NOT NULL,
|
||||||
|
"platformId" integer NOT NULL,
|
||||||
|
PRIMARY KEY("gameId","platformId")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "game_redirect" (
|
||||||
|
"id" VARCHAR NOT NULL,
|
||||||
|
"sourceId" VARCHAR NOT NULL,
|
||||||
|
"dateAdded" datetime,
|
||||||
|
PRIMARY KEY("id", "sourceId")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "game_tags_tag" (
|
||||||
|
"gameId" varchar NOT NULL,
|
||||||
|
"tagId" integer NOT NULL,
|
||||||
|
PRIMARY KEY("gameId","tagId")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "platform" (
|
||||||
|
"id" integer NOT NULL,
|
||||||
|
"dateModified" datetime NOT NULL DEFAULT (datetime('now')),
|
||||||
|
"primaryAliasId" integer,
|
||||||
|
"description" varchar NOT NULL DEFAULT '',
|
||||||
|
CONSTRAINT "REL_platform_primary_alias_unique" UNIQUE("primaryAliasId"),
|
||||||
|
PRIMARY KEY("id" AUTOINCREMENT)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "platform_alias" (
|
||||||
|
"id" integer NOT NULL,
|
||||||
|
"platformId" integer,
|
||||||
|
"name" varchar NOT NULL COLLATE NOCASE,
|
||||||
|
CONSTRAINT "UQ_platform_alias_name_unique" UNIQUE("name"),
|
||||||
|
PRIMARY KEY("id" AUTOINCREMENT)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "tag" (
|
||||||
|
"id" integer NOT NULL,
|
||||||
|
"dateModified" datetime NOT NULL DEFAULT (datetime('now')),
|
||||||
|
"primaryAliasId" integer,
|
||||||
|
"categoryId" integer,
|
||||||
|
"description" varchar NOT NULL DEFAULT '',
|
||||||
|
CONSTRAINT "REL_3c002904ab97fb1b4e61e8493c" UNIQUE("primaryAliasId"),
|
||||||
|
PRIMARY KEY("id" AUTOINCREMENT)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "tag_alias" (
|
||||||
|
"id" integer NOT NULL,
|
||||||
|
"tagId" integer,
|
||||||
|
"name" varchar NOT NULL COLLATE NOCASE,
|
||||||
|
CONSTRAINT "UQ_34d6ff6807129b3b193aea26789" UNIQUE("name"),
|
||||||
|
PRIMARY KEY("id" AUTOINCREMENT)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "tag_category" (
|
||||||
|
"id" integer NOT NULL,
|
||||||
|
"name" varchar NOT NULL COLLATE NOCASE,
|
||||||
|
"color" varchar NOT NULL,
|
||||||
|
"description" varchar,
|
||||||
|
CONSTRAINT "UQ_tag_category_name" UNIQUE("name"),
|
||||||
|
PRIMARY KEY("id" AUTOINCREMENT)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "tag_filter_index" (
|
||||||
|
"id" VARCHAR NOT NULL,
|
||||||
|
PRIMARY KEY("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "tag_filter_index_info" (
|
||||||
|
"key" VARCHAR NOT NULL, dirty INTEGER DEFAULT 1,
|
||||||
|
PRIMARY KEY("key")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "IDX_34d6ff6807129b3b193aea2678" ON "tag_alias" (
|
||||||
|
"name"
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "IDX_6366e7093c3571f85f1b5ffd4f" ON "game_tags_tag" (
|
||||||
|
"gameId"
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "IDX_d12253f0cbce01f030a9ced11d" ON "game_tags_tag" (
|
||||||
|
"tagId"
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "IDX_ext_data_extId" ON "ext_data" (
|
||||||
|
"extId"
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "IDX_ext_data_gameId" ON "ext_data" (
|
||||||
|
"gameId"
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "IDX_gameTitle" ON "game" (
|
||||||
|
"title"
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "IDX_game_activeDataId" ON "game" (
|
||||||
|
"activeDataId"
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "IDX_game_config_game_id" ON "game_config" (
|
||||||
|
"gameId"
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "IDX_lookup_dateAdded" ON "game" (
|
||||||
|
"library",
|
||||||
|
"dateAdded"
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "IDX_lookup_dateModified" ON "game" (
|
||||||
|
"library",
|
||||||
|
"dateModified"
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "IDX_lookup_developer" ON "game" (
|
||||||
|
"library",
|
||||||
|
"developer"
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "IDX_lookup_lastPlayed" ON "game" (
|
||||||
|
"library",
|
||||||
|
"lastPlayed"
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "IDX_lookup_playtime" ON "game" (
|
||||||
|
"library",
|
||||||
|
"playtime"
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "IDX_lookup_publisher" ON "game" (
|
||||||
|
"library",
|
||||||
|
"publisher"
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "IDX_lookup_series" ON "game" (
|
||||||
|
"library",
|
||||||
|
"series"
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "IDX_lookup_title" ON "game" (
|
||||||
|
"library",
|
||||||
|
"title"
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "IDX_redirect_sourceId" ON "game_redirect" (
|
||||||
|
"sourceId"
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX "IDX_total" ON "game" (
|
||||||
|
"library",
|
||||||
|
"broken",
|
||||||
|
"extreme"
|
||||||
|
);
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<!-- Pixel Penguin Panic: a tiny game made for Dotcom Arcade's demo data. Catch
|
||||||
|
the fish, dodge the rocks. Plays itself until a key is pressed. -->
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Pixel Penguin Panic</title>
|
||||||
|
<style>
|
||||||
|
html, body { margin: 0; height: 100%; background: #0b1730; }
|
||||||
|
canvas { display: block; width: 100%; height: 100%; object-fit: contain; image-rendering: pixelated; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<canvas id="game" width="640" height="480"></canvas>
|
||||||
|
<script>
|
||||||
|
const canvas = document.getElementById('game');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const W = 640, H = 480, GROUND = 420;
|
||||||
|
let penguin = { x: W / 2, dir: 0 }, things = [], score = 0, best = 0, lives = 3;
|
||||||
|
let demo = true, tick = 0, flash = 0;
|
||||||
|
const keys = {};
|
||||||
|
|
||||||
|
addEventListener('keydown', (e) => { keys[e.key] = true; if (demo) { demo = false; reset(); } });
|
||||||
|
addEventListener('keyup', (e) => { keys[e.key] = false; });
|
||||||
|
canvas.addEventListener('pointerdown', () => { if (demo) { demo = false; reset(); } });
|
||||||
|
canvas.addEventListener('pointermove', (e) => {
|
||||||
|
if (demo) return;
|
||||||
|
const r = canvas.getBoundingClientRect();
|
||||||
|
penguin.x = ((e.clientX - r.left) / r.width) * W;
|
||||||
|
});
|
||||||
|
|
||||||
|
function reset() { things = []; score = 0; lives = 3; penguin.x = W / 2; }
|
||||||
|
|
||||||
|
function spawn() {
|
||||||
|
const rock = Math.random() < 0.3;
|
||||||
|
things.push({ x: 30 + Math.random() * (W - 60), y: -20, vy: 2 + Math.random() * 2 + score / 400, rock });
|
||||||
|
}
|
||||||
|
|
||||||
|
function update() {
|
||||||
|
tick++;
|
||||||
|
if (tick % 28 === 0) spawn();
|
||||||
|
if (demo) {
|
||||||
|
const fish = things.filter((t) => !t.rock).sort((a, b) => b.y - a.y)[0];
|
||||||
|
const target = fish ? fish.x : W / 2;
|
||||||
|
penguin.x += Math.max(-5, Math.min(5, target - penguin.x));
|
||||||
|
} else {
|
||||||
|
if (keys.ArrowLeft || keys.a) penguin.x -= 6;
|
||||||
|
if (keys.ArrowRight || keys.d) penguin.x += 6;
|
||||||
|
}
|
||||||
|
penguin.x = Math.max(24, Math.min(W - 24, penguin.x));
|
||||||
|
for (const t of things) {
|
||||||
|
t.y += t.vy;
|
||||||
|
if (t.y > GROUND - 40 && t.y < GROUND && Math.abs(t.x - penguin.x) < 30) {
|
||||||
|
t.done = true;
|
||||||
|
if (t.rock) { lives--; flash = 12; if (lives <= 0) { best = Math.max(best, score); reset(); } }
|
||||||
|
else score += 10;
|
||||||
|
}
|
||||||
|
if (t.y > H) t.done = true;
|
||||||
|
}
|
||||||
|
things = things.filter((t) => !t.done);
|
||||||
|
if (flash > 0) flash--;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawPenguin(x) {
|
||||||
|
const y = GROUND;
|
||||||
|
ctx.fillStyle = '#111827'; ctx.beginPath(); ctx.ellipse(x, y - 26, 22, 28, 0, 0, Math.PI * 2); ctx.fill();
|
||||||
|
ctx.fillStyle = '#f8fafc'; ctx.beginPath(); ctx.ellipse(x, y - 22, 14, 20, 0, 0, Math.PI * 2); ctx.fill();
|
||||||
|
ctx.fillStyle = '#fff'; ctx.fillRect(x - 9, y - 46, 7, 7); ctx.fillRect(x + 3, y - 46, 7, 7);
|
||||||
|
ctx.fillStyle = '#000'; ctx.fillRect(x - 6, y - 44, 3, 4); ctx.fillRect(x + 6, y - 44, 3, 4);
|
||||||
|
ctx.fillStyle = '#f59e0b'; ctx.fillRect(x - 5, y - 36, 10, 5); ctx.fillRect(x - 16, y - 2, 12, 4); ctx.fillRect(x + 4, y - 2, 12, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawFish(t) {
|
||||||
|
ctx.fillStyle = '#38bdf8';
|
||||||
|
ctx.beginPath(); ctx.ellipse(t.x, t.y, 14, 8, 0, 0, Math.PI * 2); ctx.fill();
|
||||||
|
ctx.beginPath(); ctx.moveTo(t.x + 12, t.y); ctx.lineTo(t.x + 22, t.y - 8); ctx.lineTo(t.x + 22, t.y + 8); ctx.fill();
|
||||||
|
ctx.fillStyle = '#000'; ctx.fillRect(t.x - 8, t.y - 3, 3, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawRock(t) {
|
||||||
|
ctx.fillStyle = '#64748b';
|
||||||
|
ctx.beginPath(); ctx.arc(t.x, t.y, 13, 0, Math.PI * 2); ctx.fill();
|
||||||
|
ctx.fillStyle = '#94a3b8'; ctx.fillRect(t.x - 6, t.y - 7, 5, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw() {
|
||||||
|
const sky = ctx.createLinearGradient(0, 0, 0, H);
|
||||||
|
sky.addColorStop(0, '#1e3a8a'); sky.addColorStop(1, '#7dd3fc');
|
||||||
|
ctx.fillStyle = sky; ctx.fillRect(0, 0, W, H);
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.8)';
|
||||||
|
for (let i = 0; i < 40; i++) ctx.fillRect((i * 97 + tick * 0.3) % W, (i * 53) % 260, 2, 2);
|
||||||
|
ctx.fillStyle = '#e0f2fe'; ctx.fillRect(0, GROUND, W, H - GROUND);
|
||||||
|
ctx.fillStyle = '#bae6fd'; for (let x = 0; x < W; x += 40) ctx.fillRect(x, GROUND, 20, 6);
|
||||||
|
things.forEach((t) => (t.rock ? drawRock(t) : drawFish(t)));
|
||||||
|
drawPenguin(penguin.x);
|
||||||
|
if (flash % 4 > 1) { ctx.fillStyle = 'rgba(239,68,68,0.3)'; ctx.fillRect(0, 0, W, H); }
|
||||||
|
ctx.fillStyle = '#fff'; ctx.font = 'bold 20px monospace';
|
||||||
|
ctx.fillText(`SCORE ${String(score).padStart(5, '0')}`, 16, 32);
|
||||||
|
ctx.fillText('♥'.repeat(lives), W - 80, 32);
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.font = 'bold 34px monospace'; ctx.fillText('PIXEL PENGUIN PANIC', W / 2, 90);
|
||||||
|
if (demo && Math.floor(tick / 30) % 2) { ctx.font = 'bold 18px monospace'; ctx.fillText('PRESS ANY KEY', W / 2, 124); }
|
||||||
|
ctx.textAlign = 'left';
|
||||||
|
}
|
||||||
|
|
||||||
|
(function loop() { update(); draw(); requestAnimationFrame(loop); })();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Takes the README's screenshots (docs/images) from an app running on the demo
|
||||||
|
// data, with a fresh user database. See docs/08-development/demo-data.md.
|
||||||
|
//
|
||||||
|
// node scripts/demo/take-screenshots.mjs [base URL, default http://localhost:3100]
|
||||||
|
//
|
||||||
|
// Needs Playwright: npm install --no-save playwright && npx playwright install chromium
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { chromium } from 'playwright';
|
||||||
|
|
||||||
|
const base = process.argv[2] ?? 'http://localhost:3100';
|
||||||
|
const images = join(dirname(new URL(import.meta.url).pathname), '../../docs/images');
|
||||||
|
const user = { username: 'demo', email: 'demo@example.com', password: 'demo-password' };
|
||||||
|
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const context = await browser.newContext({
|
||||||
|
baseURL: base,
|
||||||
|
viewport: { width: 1280, height: 1060 },
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
const api = context.request;
|
||||||
|
|
||||||
|
async function call(method, url, data) {
|
||||||
|
const response = await api.fetch(url, { method, data });
|
||||||
|
if (!response.ok())
|
||||||
|
throw new Error(`${method} ${url}: ${response.status()} ${await response.text()}`);
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { needsSetup } = await call('GET', '/api/auth/setup-status');
|
||||||
|
if (needsSetup) await call('POST', '/api/auth/register', user);
|
||||||
|
const { tokens } = await call('POST', '/api/auth/login', {
|
||||||
|
username: user.username,
|
||||||
|
password: user.password,
|
||||||
|
});
|
||||||
|
const auth = { Authorization: `Bearer ${tokens.accessToken}` };
|
||||||
|
|
||||||
|
async function findGame(title) {
|
||||||
|
const response = await api.post('/api/games', {
|
||||||
|
data: { search: title, limit: 1 },
|
||||||
|
headers: auth,
|
||||||
|
});
|
||||||
|
const [game] = (await response.json()).data;
|
||||||
|
if (game?.title !== title) throw new Error(`demo game not found: ${title}`);
|
||||||
|
return game;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recently played, oldest first, for the homepage's "Jump back in"
|
||||||
|
for (const title of [
|
||||||
|
'Moon Mower',
|
||||||
|
'Jelly Jump',
|
||||||
|
'Laser Llama',
|
||||||
|
'Lemonade Empire',
|
||||||
|
'Crayon Kart',
|
||||||
|
'Drum Doodle',
|
||||||
|
'Pocket Pirates',
|
||||||
|
]) {
|
||||||
|
const game = await findGame(title);
|
||||||
|
const start = await api.post('/api/play/start', {
|
||||||
|
data: { gameId: game.id, gameTitle: game.title },
|
||||||
|
headers: auth,
|
||||||
|
});
|
||||||
|
const { sessionId } = await start.json();
|
||||||
|
await api.post('/api/play/end', { data: { sessionId }, headers: auth });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function settle() {
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await page.waitForFunction(() => [...document.images].every((image) => image.complete));
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The Game of the Day follows the browser's date; this one picks The Lost Floppy
|
||||||
|
await page.clock.setFixedTime(new Date('2026-06-08T12:00:00'));
|
||||||
|
|
||||||
|
await page.goto('/login');
|
||||||
|
await page.getByPlaceholder('Enter your username').fill(user.username);
|
||||||
|
await page.getByPlaceholder('Enter your password').fill(user.password);
|
||||||
|
await page.locator('button[type="submit"]').click();
|
||||||
|
await page.waitForURL((url) => url.pathname !== '/login');
|
||||||
|
await page.goto('/');
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
// The Time Machine opens on a random week; travel to the one the demo fills
|
||||||
|
await page.getByTitle('Pick a date').click();
|
||||||
|
await page.getByRole('combobox', { name: /month/i }).selectOption({ label: 'Jun' });
|
||||||
|
await page.getByRole('combobox', { name: /year/i }).selectOption('2008');
|
||||||
|
await page.getByRole('button', { name: /June 16(th)?, 2008/ }).click();
|
||||||
|
await settle();
|
||||||
|
await page.screenshot({ path: join(images, 'home.png') });
|
||||||
|
|
||||||
|
const penguin = await findGame('Pixel Penguin Panic');
|
||||||
|
await page.goto(`/browse/${penguin.id}`);
|
||||||
|
await settle();
|
||||||
|
await page.screenshot({ path: join(images, 'game.png') });
|
||||||
|
|
||||||
|
await page
|
||||||
|
.getByRole('button', { name: /^play$/i })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
await page.waitForTimeout(6000);
|
||||||
|
await page.screenshot({ path: join(images, 'playing.png') });
|
||||||
|
|
||||||
|
await browser.close();
|
||||||
|
console.log(`Screenshots written to ${images}`);
|
||||||
Executable
+41
@@ -0,0 +1,41 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Shows what upstream flashpoint-web has changed since it was last merged, and
|
||||||
|
# how this project stands on each file it touches. Changes nothing but the
|
||||||
|
# refs/remotes/upstream/master ref. See docs/08-development/upstream-sync.md.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
URL=${UPSTREAM_URL:-https://github.com/darkraise/flashpoint-web.git}
|
||||||
|
BRANCH=master
|
||||||
|
UPSTREAM=refs/remotes/upstream/$BRANCH
|
||||||
|
|
||||||
|
cd "$(git rev-parse --show-toplevel)"
|
||||||
|
git fetch -q --no-tags "$URL" "+$BRANCH:$UPSTREAM"
|
||||||
|
base=$(git merge-base HEAD "$UPSTREAM")
|
||||||
|
count=$(git rev-list --count "$base..$UPSTREAM")
|
||||||
|
|
||||||
|
echo "Last merged from upstream: $(git log -1 --format='%h %ad %s' --date=short "$base")"
|
||||||
|
if ((count == 0)); then
|
||||||
|
echo "Up to date with upstream $BRANCH."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "$count new upstream commit(s):"
|
||||||
|
git log --format=' %h %ad %s' --date=short "$base..$UPSTREAM"
|
||||||
|
echo
|
||||||
|
echo "Files they change, by where this project stands on each:"
|
||||||
|
echo " unchanged here: merges as is changed here: merge both sides"
|
||||||
|
echo " removed here: keep it removed new upstream: take it, unless it belongs to a removed area"
|
||||||
|
echo
|
||||||
|
git diff --name-only "$base" "$UPSTREAM" | while IFS= read -r file; do
|
||||||
|
if ! git cat-file -e "HEAD:$file" 2>/dev/null; then
|
||||||
|
if git cat-file -e "$base:$file" 2>/dev/null; then
|
||||||
|
echo "removed here $file"
|
||||||
|
else
|
||||||
|
echo "new upstream $file"
|
||||||
|
fi
|
||||||
|
elif git diff --quiet "$base" HEAD -- "$file"; then
|
||||||
|
echo "unchanged here $file"
|
||||||
|
else
|
||||||
|
echo "changed here $file"
|
||||||
|
fi
|
||||||
|
done | sort
|
||||||
@@ -46,8 +46,9 @@ export class DockerDriver implements Driver {
|
|||||||
`PUID=${uid}`,
|
`PUID=${uid}`,
|
||||||
`PGID=${gid}`,
|
`PGID=${gid}`,
|
||||||
`SUBFOLDER=${streamPath(id)}`,
|
`SUBFOLDER=${streamPath(id)}`,
|
||||||
`SELKIES_MANUAL_WIDTH=${size.width}`,
|
// The game's size, rounded up to even for H.264
|
||||||
`SELKIES_MANUAL_HEIGHT=${size.height}`,
|
`SELKIES_MANUAL_WIDTH=${size.width + (size.width % 2)}`,
|
||||||
|
`SELKIES_MANUAL_HEIGHT=${size.height + (size.height % 2)}`,
|
||||||
'SELKIES_UI_SHOW_SIDEBAR=false',
|
'SELKIES_UI_SHOW_SIDEBAR=false',
|
||||||
'HARDEN_DESKTOP=true',
|
'HARDEN_DESKTOP=true',
|
||||||
'RESTART_APP=true',
|
'RESTART_APP=true',
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export class GameCatalog {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const page = await this.readContent(gameId, row, row.cmd).catch(() => null);
|
const page = await this.readContent(gameId, row, row.cmd).catch(() => null);
|
||||||
return even((page && appletSize(page.toString('latin1'))) ?? DEFAULT_SIZE);
|
return ((page && appletSize(page.toString('latin1'))) ?? DEFAULT_SIZE);
|
||||||
}
|
}
|
||||||
const program = await this.launchProgram(row);
|
const program = await this.launchProgram(row);
|
||||||
if (!program) {
|
if (!program) {
|
||||||
@@ -68,19 +68,19 @@ export class GameCatalog {
|
|||||||
const url = row.cmd.match(/https?:\/\/[^\s"']+/)?.[0];
|
const url = row.cmd.match(/https?:\/\/[^\s"']+/)?.[0];
|
||||||
if (!url) {
|
if (!url) {
|
||||||
const exe = await readFile(program).catch(() => null);
|
const exe = await readFile(program).catch(() => null);
|
||||||
return even((exe && projectorStageSize(exe)) ?? DEFAULT_SIZE);
|
return ((exe && projectorStageSize(exe)) ?? DEFAULT_SIZE);
|
||||||
}
|
}
|
||||||
const content = await this.readContent(gameId, row, url).catch(() => null);
|
const content = await this.readContent(gameId, row, url).catch(() => null);
|
||||||
const extension = path.extname(new URL(url).pathname).toLowerCase();
|
const extension = path.extname(new URL(url).pathname).toLowerCase();
|
||||||
if (['.dcr', '.dir', '.dxr', '.cct', '.cst'].includes(extension)) {
|
if (['.dcr', '.dir', '.dxr', '.cct', '.cst'].includes(extension)) {
|
||||||
return even((content && directorStageSize(content)) ?? DEFAULT_SIZE);
|
return ((content && directorStageSize(content)) ?? DEFAULT_SIZE);
|
||||||
}
|
}
|
||||||
if (extension === '.swf') {
|
if (extension === '.swf') {
|
||||||
// The exact stage: in full screen, games that set noScale keep their native size
|
// The exact stage, so the movie fills the display without scaling
|
||||||
return even((content && swfStageSize(content)) ?? DEFAULT_SIZE);
|
return ((content && swfStageSize(content)) ?? DEFAULT_SIZE);
|
||||||
}
|
}
|
||||||
const embed = content && embedSize(content.toString('latin1'));
|
const embed = content && embedSize(content.toString('latin1'));
|
||||||
return even(embed ? { width: embed.width + 2 * PAGE_MARGIN, height: embed.height + 2 * PAGE_MARGIN } : BROWSER_SIZE);
|
return (embed ? { width: embed.width + 2 * PAGE_MARGIN, height: embed.height + 2 * PAGE_MARGIN } : BROWSER_SIZE);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -151,11 +151,6 @@ async function resolveCaseInsensitive(root: string, relative: string): Promise<s
|
|||||||
return (await stat(current).catch(() => null))?.isFile() ? current : null;
|
return (await stat(current).catch(() => null))?.isFile() ? current : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** H.264 needs even dimensions. */
|
|
||||||
function even({ width, height }: Size): Size {
|
|
||||||
return { width: width + (width % 2), height: height + (height % 2) };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reads the stage size from a SWF header: a rect of twips after the 8-byte header. */
|
/** Reads the stage size from a SWF header: a rect of twips after the 8-byte header. */
|
||||||
export function swfStageSize(swf: Buffer): Size | null {
|
export function swfStageSize(swf: Buffer): Size | null {
|
||||||
const signature = swf.toString('latin1', 0, 3);
|
const signature = swf.toString('latin1', 0, 3);
|
||||||
|
|||||||
@@ -177,11 +177,11 @@ test('GameCatalog streams allowed platforms whose launcher resolves, sized from
|
|||||||
legacy: { width: 300, height: 200 },
|
legacy: { width: 300, height: 200 },
|
||||||
missing: { width: 800, height: 600 },
|
missing: { width: 800, height: 600 },
|
||||||
webstart: null,
|
webstart: null,
|
||||||
spr: { width: 500, height: 426 },
|
spr: { width: 500, height: 425 },
|
||||||
oldcpu: { width: 500, height: 426 },
|
oldcpu: { width: 500, height: 425 },
|
||||||
secure: { width: 640, height: 480 },
|
secure: { width: 640, height: 480 },
|
||||||
navigator: { width: 528, height: 400 },
|
navigator: { width: 528, height: 400 },
|
||||||
flash: { width: 500, height: 376 },
|
flash: { width: 500, height: 375 },
|
||||||
unity: null,
|
unity: null,
|
||||||
'no-program': null,
|
'no-program': null,
|
||||||
exe: { width: 800, height: 600 },
|
exe: { width: 800, height: 600 },
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ RUN apt-get update && \
|
|||||||
libxtst6 \
|
libxtst6 \
|
||||||
libxxf86vm1 \
|
libxxf86vm1 \
|
||||||
sqlite3 \
|
sqlite3 \
|
||||||
|
x11-utils \
|
||||||
xdotool && \
|
xdotool && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ writable mirror of the install, since they write beside themselves; a
|
|||||||
Director projector's `Prefs`, where it saves, is linked into `/config/saves`, as
|
Director projector's `Prefs`, where it saves, is linked into `/config/saves`, as
|
||||||
is Flash Player's save directory, so the session manager syncs both to the
|
is Flash Player's save directory, so the session manager syncs both to the
|
||||||
player's browser. OldCPUSimulator hangs under Wine, so its target runs under
|
player's browser. OldCPUSimulator hangs under Wine, so its target runs under
|
||||||
`cpulimit` at the same share of the CPU. Flash projectors switch to full screen,
|
`cpulimit` at the same share of the CPU. A Flash projector's window is
|
||||||
which hides their menu bar and scales the movie to the display, and Flashpoint
|
stretched to the display and moved up by its menu bar's height, which hides the
|
||||||
Navigator's toolbars are hidden.
|
menu bar. GTK draws the menu bar in the window itself and the movie in a child
|
||||||
|
window below it, so the movie's offset, read with `xwininfo`, is the menu bar's
|
||||||
|
height. The window is made override-redirect first, since the window manager
|
||||||
|
keeps windows on screen, and is kept in place while it's open, since Flash
|
||||||
|
Player moves it back once the movie has loaded. Its full-screen mode isn't used, since some games, such
|
||||||
|
as Bloons, don't play right in it. Flashpoint Navigator's toolbars are hidden.
|
||||||
|
|||||||
@@ -47,8 +47,42 @@ case ${app,,} in
|
|||||||
mkdir -p "$HOME/saves/Flash_Player" "$HOME/.macromedia"
|
mkdir -p "$HOME/saves/Flash_Player" "$HOME/.macromedia"
|
||||||
[[ -e $HOME/.macromedia/Flash_Player ]] || ln -s "$HOME/saves/Flash_Player" "$HOME/.macromedia/Flash_Player"
|
[[ -e $HOME/.macromedia/Flash_Player ]] || ln -s "$HOME/saves/Flash_Player" "$HOME/.macromedia/Flash_Player"
|
||||||
if [[ ${app,,} == 'fpsoftware\flash\'* ]]; then
|
if [[ ${app,,} == 'fpsoftware\flash\'* ]]; then
|
||||||
# Full screen hides the projector's menu bar and scales the movie to the display
|
# Hide the projector's menu bar by moving it above the display, and fill
|
||||||
(xdotool search --sync --onlyvisible --name 'Flash Player' windowactivate --sync key ctrl+f) &
|
# the display with the movie. Not full screen, which some games, such as
|
||||||
|
# Bloons, don't play right in. The window manager keeps windows on
|
||||||
|
# screen, so the window is taken out of its hands first.
|
||||||
|
(
|
||||||
|
set +eo pipefail
|
||||||
|
wid=$(xdotool search --sync --onlyvisible --name 'Flash Player' | head -1)
|
||||||
|
read -r width height < <(xdotool getdisplaygeometry)
|
||||||
|
# GTK draws the menu bar in the window itself, and the movie in the
|
||||||
|
# largest child window, below it: the movie's offset is the menu bar's height
|
||||||
|
menu=$(xwininfo -children -id "$wid" | awk '
|
||||||
|
match($0, /[0-9]+x[0-9]+\+-?[0-9]+\+-?[0-9]+/) {
|
||||||
|
split(substr($0, RSTART, RLENGTH), g, /[x+]/)
|
||||||
|
if (g[1] * g[2] > area) { area = g[1] * g[2]; top = g[4] }
|
||||||
|
}
|
||||||
|
END { print top + 0 }')
|
||||||
|
((menu >= 0 && menu < height)) || menu=0
|
||||||
|
echo "Flash Player window $wid: menu bar ${menu}px, display ${width}x${height}"
|
||||||
|
xdotool windowunmap --sync "$wid"
|
||||||
|
xdotool set_window --overrideredirect 1 "$wid"
|
||||||
|
xdotool windowmap --sync "$wid"
|
||||||
|
place() { xdotool windowsize "$wid" "$width" $((height + menu)) windowmove "$wid" 0 $((-menu)); }
|
||||||
|
place
|
||||||
|
xdotool windowfocus "$wid"
|
||||||
|
# Flash Player moves its window back once the movie has loaded, so it's
|
||||||
|
# kept in place for as long as it's open
|
||||||
|
moves=0
|
||||||
|
while geometry=$(xdotool getwindowgeometry --shell "$wid" 2>/dev/null); do
|
||||||
|
eval "$geometry"
|
||||||
|
if ((X != 0 || Y != -menu || WIDTH != width || HEIGHT != height + menu)); then
|
||||||
|
((++moves <= 3)) && echo "Flash Player window moved to $X,$Y (${WIDTH}x${HEIGHT}); putting it back"
|
||||||
|
place
|
||||||
|
fi
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
) &
|
||||||
fi
|
fi
|
||||||
cd "${argv[0]}"
|
cd "${argv[0]}"
|
||||||
exec "${argv[@]:1}" ;;
|
exec "${argv[@]:1}" ;;
|
||||||
|
|||||||
Reference in New Issue
Block a user