Compare commits

..
Author SHA1 Message Date
branderson a84ba7f6de Merge pull request 'Retry image builds once after a GHCR 429' (#17) from ci-retry-ghcr into main
Images / app (push) Successful in 4m56s
Images / session-manager (push) Successful in 1m49s
Images / streamer (push) Successful in 3m40s
Reviewed-on: #17
Reviewed-by: branderson <brad@branderson.io>
2026-10-15 21:29:32 -07:00
branderson d45aa8b603 ci: retry forge image builds once after a GHCR 429 too 2026-10-15 21:28:37 -07:00
branderson d54a44da1d ci: retry GitHub image builds once after a GHCR 429
The streamer's base image pull from ghcr.io intermittently gets 429 Too
Many Requests on GitHub's shared runners, failing the first attempt of
the last two runs; re-running minutes later succeeded both times.
2026-10-15 20:05:38 -07:00
branderson bf175b64a2 Merge pull request 'Retake screenshots on made-up demo data' (#16) from demo-screenshots into main
Images / app (push) Successful in 5m47s
Images / session-manager (push) Successful in 7m22s
Images / streamer (push) Failing after 1m14s
Reviewed-on: #16
Reviewed-by: branderson <brad@branderson.io>
2026-10-15 19:47:24 -07:00
branderson 245f499f04 docs: note restarting the app between screenshot runs 2026-10-15 19:37:05 -07:00
branderson c1350387b1 docs: draw demo game art as genre scenes
Tiles and screenshots were abstract shapes. Each is now a small scene for
its genre, with several layouts per genre steered by the game's tags, so
no two games look alike. The screenshot script pins the browser's date so
the Game of the Day doesn't change between runs.
2026-10-15 19:36:42 -07:00
branderson 5357622c2d docs: retake screenshots on made-up demo data
The README's screenshots showed real games whose authors never agreed to
be featured. scripts/demo/build-demo-data.mjs builds a Flashpoint install
of invented games with generated art, plus Pixel Penguin Panic, a small
HTML5 game written for it. scripts/demo/take-screenshots.mjs retakes the
screenshots from an app running on it.
2026-10-15 19:13:11 -07:00
branderson 89194dd94a Merge pull request 'Publish only a latest image tag' (#15) from latest-tag-only into main
Images / app (push) Successful in 4m49s
Images / session-manager (push) Successful in 1m43s
Images / streamer (push) Successful in 3m32s
Reviewed-on: #15
Reviewed-by: branderson <brad@branderson.io>
2026-10-15 17:35:04 -07:00
branderson 97168ea0e5 ci: publish only a latest image tag, built from main
Drops version tag builds and the main tag. Images report the short
commit SHA as their version.
2026-10-15 17:34:14 -07:00
branderson a2d899be6f Merge pull request 'Mirror main to GitHub and publish images to GHCR' (#14) from github-mirror into main
Images / app (push) Successful in 5m35s
Images / session-manager (push) Successful in 1m37s
Images / streamer (push) Successful in 3m32s
Reviewed-on: #14
Reviewed-by: branderson <brad@branderson.io>
2026-10-15 17:00:58 -07:00
branderson 2d1d8cce46 feat: mirror main to GitHub and publish images to GHCR
mirror/github-mirror.sh publishes main and its tags to a read-only
GitHub mirror, adding a notice under the README title, with a systemd
timer to run it hourly. A GitHub Actions workflow builds the images on
the mirror and pushes them to ghcr.io. The docs point prebuilt images
there, and CONTRIBUTING no longer invites issues and pull requests.

upstream-status.sh now fetches without tags, so upstream's version tags
stay out of local clones.
2026-10-15 16:48:04 -07:00
branderson 3c3bb5dfbf Merge pull request 'Add upstream sync process and agent notes' (#13) from upstream-sync-process into main
Images / app (push) Successful in 4m55s
Images / session-manager (push) Successful in 1m46s
Images / streamer (push) Successful in 3m44s
Reviewed-on: #13
Reviewed-by: branderson <brad@branderson.io>
2026-10-15 16:36:56 -07:00
22 changed files with 2846 additions and 122 deletions
+7
View File
@@ -34,6 +34,13 @@ architecture notes are in `CLAUDE.md`; feature and deployment docs are in
- `.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
+1 -1
View File
@@ -13,7 +13,7 @@ JWT_SECRET=replace-with-a-secure-random-string
# Built from source by default. To pull published images instead, set the
# registry path they live under (ending in /), then:
# docker compose pull && docker compose up -d
# IMAGE_PREFIX=registry.example.com/owner/
# IMAGE_PREFIX=ghcr.io/branderson/
# IMAGE_TAG=latest
# "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
+30 -8
View File
@@ -10,7 +10,6 @@ name: Images
on:
push:
branches: [main]
tags: ['v*.*.*']
workflow_dispatch:
# Jobs that run docker builds use dejima-privileged; anything else should use
@@ -59,6 +58,12 @@ jobs:
echo "host=$host" >> "$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/login-action@v3
@@ -71,13 +76,13 @@ jobs:
uses: docker/metadata-action@v5
with:
images: ${{ steps.registry.outputs.host }}/${{ steps.registry.outputs.owner }}/${{ matrix.image }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=ref,event=branch
tags: type=raw,value=latest
- 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:
context: ${{ matrix.context }}
push: true
@@ -85,5 +90,22 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64
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
+91
View File
@@ -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
View File
@@ -1,44 +1,11 @@
# 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?
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
```
It's built on [flashpoint-web](https://github.com/darkraise/flashpoint-web).
Fixes to code the two still share may belong upstream.
## Development Setup
@@ -47,6 +14,7 @@ git rebase upstream/master
```
backend/ # Express REST API + game content serving (:3100)
frontend/ # React + Vite application (:5173)
streaming/ # Session manager and streamer for games browsers can't run
docs/ # Documentation
```
@@ -69,6 +37,7 @@ The app (UI and API) runs on `http://localhost:80`.
### Option 2: Manual Setup (Recommended for Development)
**Prerequisites:**
- Node.js 20+
- Flashpoint Archive installed locally
@@ -99,80 +68,21 @@ cd backend && npm test -- --coverage
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
See [CLAUDE.md](CLAUDE.md) for detailed coding standards. Key points:
- **TypeScript:** No `any` types, use `unknown` or proper interfaces
- **Backend:** Wrap async handlers with `asyncHandler()`, static routes before parameterized
- **Frontend:** Use `@/` imports, all API calls through `lib/api.ts`, use theme tokens
- **Backend:** Wrap async handlers with `asyncHandler()`, static routes before
parameterized
- **Frontend:** Use `@/` imports, all API calls through `lib/api.ts`, use theme
tokens
- **General:** Validate inputs, handle errors properly, clean up resources
Run `npm run format` before committing.
## 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
Run `npm run format` before committing. Commit messages follow
[Conventional Commits](https://www.conventionalcommits.org/).
## 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.
+3
View File
@@ -135,6 +135,9 @@ PGID=1000
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
indexes the collection, which takes a few minutes on a full Ultimate install.
Under **Settings → App** you can set the site name and tagline.
+2
View File
@@ -16,6 +16,8 @@ the Dotcom Arcade application.
6. **[Debugging Guide](./debugging.md)** - Debugging tips and troubleshooting
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
+45
View File
@@ -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.
+28
View File
@@ -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/`.
+15 -7
View File
@@ -28,22 +28,30 @@ All three are built from this repository by `docker compose up -d --build`.
### Prebuilt images
To pull published images instead of building, set `IMAGE_PREFIX` in `.env` to
the registry path they live under, ending in `/`, and optionally `IMAGE_TAG`:
Images are published to GitHub's container registry. To pull them instead of
building, set `IMAGE_PREFIX` in `.env`, ending in `/`. There's one tag,
`latest`, built from the tip of `main`.
```sh
IMAGE_PREFIX=registry.example.com/owner/
IMAGE_TAG=latest
IMAGE_PREFIX=ghcr.io/branderson/
```
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
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
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`,
`1.2` and `latest`. Its jobs run docker builds, so they need a runner labelled
owner. Each push to `main` publishes `latest`. Its jobs run docker builds, so they need a runner labelled
`dejima-privileged`; jobs that don't build images should use `dejima-confined`.
It also needs a repository secret, `REGISTRY_TOKEN`: an access token with
package write access, belonging to the repository's owner, whom the workflow
+2
View File
@@ -126,6 +126,8 @@ for Flashpoint Ultimate.
- [Debugging](08-development/debugging.md) - Debugging tips
- [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
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

@@ -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
+9
View File
@@ -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
+133
View File
@@ -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
+246
View File
@@ -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"
);
+109
View File
@@ -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>
+105
View File
@@ -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}`);
+1 -1
View File
@@ -9,7 +9,7 @@ BRANCH=master
UPSTREAM=refs/remotes/upstream/$BRANCH
cd "$(git rev-parse --show-toplevel)"
git fetch -q "$URL" "+$BRANCH:$UPSTREAM"
git fetch -q --no-tags "$URL" "+$BRANCH:$UPSTREAM"
base=$(git merge-base HEAD "$UPSTREAM")
count=$(git rev-list --count "$base..$UPSTREAM")