Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
83b3ab69a8
30 changed files with 7508 additions and 0 deletions
26
.github/dependabot.yml
vendored
Normal file
26
.github/dependabot.yml
vendored
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# Dependabot configuration to update npm packages weekly
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/" # Location of your package.json and package-lock.json
|
||||
schedule:
|
||||
interval: "cron"
|
||||
cronjob: "0 17 * * 1,2" # At 5pm on Monday and Tuesday - https://crontab.guru/
|
||||
timezone: "America/New_York"
|
||||
open-pull-requests-limit: 10 # Optional: prevents PR spam by limiting active updates
|
||||
ignore:
|
||||
- dependency-name: "@types/node"
|
||||
update-types: ["version-update:semver-major"]
|
||||
cooldown:
|
||||
exclude:
|
||||
- "devvit"
|
||||
- "@devvit/*"
|
||||
groups:
|
||||
devvit:
|
||||
patterns:
|
||||
- "devvit"
|
||||
- "@devvit/*"
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
35
.github/workflows/ci.yaml
vendored
Normal file
35
.github/workflows/ci.yaml
vendored
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 24.18.0
|
||||
- name: Install Dependencies
|
||||
run: npm install --no-fund
|
||||
- parallel:
|
||||
- name: Typecheck
|
||||
run: npm run test:types
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
- name: Unit Test
|
||||
run: npm run test:unit
|
||||
- name: Build
|
||||
run: npm run build
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
/node_modules/
|
||||
/dist/
|
||||
/public/*.js*
|
||||
/.env
|
||||
1
.nvmrc
Normal file
1
.nvmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
24.18.0
|
||||
1
.vscode/extensions.json
vendored
Normal file
1
.vscode/extensions.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"recommendations": ["biomejs.biome"]}
|
||||
5
.vscode/settings.json
vendored
Normal file
5
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.codeActionsOnSave": {"source.organizeImports.biome": "explicit"},
|
||||
"js/ts.tsdk.path": "node_modules/typescript/lib"
|
||||
}
|
||||
100
CLAUDE.md
Normal file
100
CLAUDE.md
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
# CLAUDE.md – Ninelands für Reddit (Devvit)
|
||||
|
||||
Diese Datei lädt Claude Code in jeder Sitzung. Sie hält fest, **was** dieses Projekt ist, **wie** gearbeitet wird und
|
||||
**wo** wir stehen. Stand: 2026-11-13, frisch angelegt aus der Devvit-Vorlage **Bare**.
|
||||
|
||||
## Projekt
|
||||
|
||||
- **Ninelands auf Reddit**: eine Basic-Version von [Ninelands](https://ninelands.stackteam.at) als Reddit-Spiel
|
||||
(Devvit Web) für den Jam **Reddit Game of the Day 2026** – https://redditgameofday2026.devpost.com/ (Regeln:
|
||||
`/rules`). Besitzer: Lukas Müllner (StackTeam). Mit dem Besitzer **Deutsch** sprechen.
|
||||
- App-Name in `devvit.json`: `ninelands-game`. Ordner: `/data/Documents/projects/ninelands-game`.
|
||||
- Geplantes Repo: `DieLustigeTierwesen/ninelands-reddit` auf `git.stackteam.at` (Forgejo, noch nicht angelegt).
|
||||
- **Was die Basic-Version können soll, ist noch offen** – das klären wir zuerst gemeinsam (Spieldesign entscheidet der
|
||||
Besitzer). Vorher nichts Spielerisches bauen.
|
||||
|
||||
## Woher Fragen beantwortet werden
|
||||
|
||||
- **Das Hauptspiel** (Regeln, Engine, Gestaltung): Repo `DieLustigeTierwesen/DokuHive`, lokal
|
||||
`/data/Documents/projects/hivedoku` – dort `README.md`, `hicedoku_plan.md` (Entscheidungen „as built“),
|
||||
Leitfaden `guide/chapters/`. Bei Fragen zu Regeln oder Absicht **dort nachlesen**, nicht raten.
|
||||
- **Allgemeiner Spiele-Blueprint** (Arbeitsweise, Fallstricke, Releases): Repo `DieLustigeTierwesen/game-blueprint`,
|
||||
lokal `/data/Documents/projects/game-blueprint/AGENT.md`. Erkenntnisse, die für künftige Spiele gelten (z. B. „wie
|
||||
baut man ein Devvit-Spiel“), dort nachtragen.
|
||||
|
||||
## Grenzen zum Hauptspiel
|
||||
|
||||
- **In `/data/Documents/projects/hivedoku` nichts ändern, committen oder pushen.** Jeder Push auf dessen `main`
|
||||
deployt die Live-Seite; Releases dort (itch, Desktop, Android) laufen über `npm run release` und gehören nicht zu
|
||||
diesem Projekt.
|
||||
- Die Engine von Ninelands (`hivedoku/src/core`, reines TypeScript ohne DOM/Server, seeded RNG, `applyMove`,
|
||||
`legalMoves`, Replay) darf übernommen werden – als **Kopie mit festem Stand** (z. B. Sync-Skript, das `src/core` von
|
||||
einem Ninelands-Tag holt und den Tag notiert). Die Kopie hier **nicht von Hand ändern**; Änderungen an der Engine
|
||||
gehören ins Hauptspiel. Achtung: `src/core` importiert JSON aus `../campaign/` und `../puzzles/` – nur mitnehmen,
|
||||
was die Basic-Version braucht.
|
||||
- **Lizenz klären, bevor Engine-Code kopiert wird:** Ninelands steht unter **GPL-3.0**, diese Vorlage unter
|
||||
**BSD-3-Clause** (`LICENSE` stammt aus der Devvit-Vorlage). Kopierte GPL-Teile machen das Ganze GPL – die Lizenz
|
||||
entscheidet der Besitzer (auch, ob der Jam eine Open-Source-Lizenz verlangt).
|
||||
|
||||
## Jam – vor der Arbeit prüfen
|
||||
|
||||
Die Devpost-Seiten blockieren automatische Abrufe (403). Der Besitzer liefert die Regeln; offen sind:
|
||||
|
||||
- Darf ein **bestehendes Spiel** (Basic-Version von Ninelands) eingereicht werden, oder nur während des Jams
|
||||
Entstandenes? Muss Vorhandenes angegeben werden?
|
||||
- Abgabefrist, Pflichtteile der Einreichung (Video, Repo-Link, Test-Subreddit, Beschreibung), Bewertungskriterien,
|
||||
Regeln zu KI-Nutzung und Fremdcode.
|
||||
|
||||
Ergebnis hier unter „Stand“ festhalten.
|
||||
|
||||
## Technik (Vorlage Bare)
|
||||
|
||||
- Devvit Web: Client `src/client/` (Splash `splash.ts` → `public/splash.html`, Spiel `game.ts` → `public/game.html`),
|
||||
Server `src/server/` (`index.ts`, `server.ts`, Speicher `db.ts`), gemeinsame Typen `src/shared/api.ts`.
|
||||
Gebaut mit **esbuild** nach `public/` und `dist/server/`.
|
||||
- Werkzeuge der Vorlage beibehalten: **Biome** (Lint + Format, `npm run lint` / `npm run format`) statt Prettier,
|
||||
Tests mit `node --test` (`*.test.ts`), Typprüfung `tsc --build`.
|
||||
- Befehle: `npm run test` (Typen, Lint, Unit-Tests, Build – **das ist die Prüfung vor jedem Merge**),
|
||||
`npm run playtest [r/sub]` (baut, lädt hoch, installiert im Test-Subreddit), `npm run publish` (lädt hoch und
|
||||
stellt einen **Review-Antrag** bei Reddit – nur nach Rückfrage beim Besitzer).
|
||||
- Node 24 über nvm (global ist 20): Befehle mit `source ~/.nvm/nvm.sh && nvm use 24 >/dev/null &&` beginnen.
|
||||
- Kein eigener Server, keine eigene Datenbank, keine eigenen Konten: Spieler sind Reddit-Nutzer, gespeichert wird
|
||||
über Devvit (Redis). Externe Netzaufrufe sind bei Devvit eingeschränkt – vor jeder Idee, die das Ninelands-Backend
|
||||
anspricht, in der Devvit-Doku prüfen (https://developers.reddit.com/docs).
|
||||
- Aktuelle Devvit-Doku lesen, bevor etwas gebaut wird – die Plattform ändert sich schnell; nicht aus dem Gedächtnis
|
||||
arbeiten.
|
||||
- `.github/workflows/ci.yaml` stammt aus der Vorlage. Forgejo führt `.github/workflows` aus, wenn es kein
|
||||
`.forgejo/workflows` gibt; `runs-on: ubuntu-latest` ist der gemeinsame Hetzner-Runner (für `npm run test` reicht er).
|
||||
|
||||
## Arbeitsweise
|
||||
|
||||
- **Ein Branch pro Änderung** (`feat/…`, `fix/…`, `chore/…`), auch für Doku.
|
||||
- **Merge nur, wenn `npm run test` mit Exit-Code 0 endet.** Ausgabe in eine Log-Datei, dann `$?` prüfen – nie mit
|
||||
`| grep … &&` oder `;` verketten. Muster: `npm run test > log 2>&1; code=$?; if [ $code -eq 0 ]; then … merge; fi`.
|
||||
- `git merge --no-ff`, Branch löschen, `main` pushen (sobald ein Remote existiert), im Bericht erwähnen.
|
||||
- Conventional Commits; Ende jeder Nachricht: `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`.
|
||||
- Code-Kommentare auf Deutsch.
|
||||
- Größere Entscheidungen und Fallstricke in `PLAN.md` (Abschnitt „as built“, Deutsch) festhalten; am Ende eines
|
||||
Schritts Erkenntnisse von sich aus nennen.
|
||||
- Innerhalb eines vereinbarten Plans selbstständig weitermachen; anhalten bei Spieldesign, Geschmack, externen Konten,
|
||||
Geld, Lizenz und allem, was öffentlich wird (`devvit publish`, Posts in echten Subreddits).
|
||||
- **Kein Geld ausgeben.**
|
||||
|
||||
## Geheimnisse – nie ausgeben, nie committen
|
||||
|
||||
- Devvit-Token `~/.devvit/token` (Reddit-Anmeldung des Besitzers): nie lesen, ausgeben oder kopieren; die
|
||||
`devvit`-Befehle nutzen ihn selbst.
|
||||
- Forgejo-Token `~/.config/forgejo-token`: nur innerhalb von Befehlen lesen.
|
||||
- Nutzer sollen nie Schlüssel in den Chat kopieren.
|
||||
|
||||
## Stand (2026-11-13)
|
||||
|
||||
- Vorlage **Bare** angelegt (`npm create devvit`), `git init` ohne Commit, kein Remote.
|
||||
- Nächste Schritte:
|
||||
1. Erster Commit der Vorlage (+ diese Datei); Repo `DieLustigeTierwesen/ninelands-reddit` anlegen (nach Rückfrage)
|
||||
und pushen.
|
||||
2. Jam-Regeln vom Besitzer übernehmen (siehe oben).
|
||||
3. Mit dem Besitzer festlegen, was die Basic-Version kann.
|
||||
4. Lizenz entscheiden, dann Engine-Kopie + Sync-Skript.
|
||||
|
||||
Diese Datei bei neuen Arbeitsregeln und Entscheidungen aktualisieren.
|
||||
26
LICENSE
Normal file
26
LICENSE
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# License (BSD-3-Clause)
|
||||
|
||||
Copyright (c) 2025 Reddit Inc.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification,
|
||||
are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
3. Neither the name of the copyright holder nor the names of its contributors
|
||||
may be used to endorse or promote products derived from this software without
|
||||
specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND ANY
|
||||
EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE FOR ANY
|
||||
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
37
biome.jsonc
Normal file
37
biome.jsonc
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.6.4/schema.json",
|
||||
"css": {"formatter": {"enabled": true, "quoteStyle": "single"}},
|
||||
"files": {"includes": ["**", "!dist/", "!public/*.js*"]},
|
||||
"formatter": {
|
||||
"bracketSpacing": false,
|
||||
"enabled": true,
|
||||
"indentStyle": "space"
|
||||
},
|
||||
"html": {"formatter": {"enabled": true}},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"arrowParentheses": "asNeeded",
|
||||
"jsxQuoteStyle": "single",
|
||||
"quoteStyle": "single",
|
||||
"semicolons": "asNeeded"
|
||||
}
|
||||
},
|
||||
"linter": {
|
||||
"domains": {"project": "all"},
|
||||
"rules": {
|
||||
"correctness": {
|
||||
// Biome resolves conditional exports without the browser condition,
|
||||
// which reports false positives for `@devvit/web/client` named exports.
|
||||
"noUnresolvedImports": "off"
|
||||
},
|
||||
"nursery": {
|
||||
"noFloatingPromises": "error",
|
||||
"noMisusedPromises": "error",
|
||||
"recommended": true
|
||||
},
|
||||
"style": {"noInferrableTypes": "off"},
|
||||
"suspicious": {"noImplicitAnyLet": "off"}
|
||||
}
|
||||
},
|
||||
"vcs": {"enabled": true, "clientKind": "git", "useIgnoreFile": true}
|
||||
}
|
||||
31
devvit.json
Normal file
31
devvit.json
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"$schema": "https://developers.reddit.com/schema/config-file.v1.json",
|
||||
"name": "ninelands-game",
|
||||
"post": {
|
||||
"entrypoints": {
|
||||
"default": {
|
||||
"entry": "splash.html"
|
||||
},
|
||||
"game": {
|
||||
"entry": "game.html"
|
||||
}
|
||||
}
|
||||
},
|
||||
"server": {},
|
||||
"menu": {
|
||||
"items": [
|
||||
{
|
||||
"label": "[ninelands-game] New Post",
|
||||
"description": "Create a new ninelands-game post.",
|
||||
"location": "subreddit",
|
||||
"endpoint": "/internal/on/menu/new-post"
|
||||
}
|
||||
]
|
||||
},
|
||||
"triggers": {
|
||||
"onAppInstall": "/internal/on/app/install"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "npm run watch"
|
||||
}
|
||||
}
|
||||
6699
package-lock.json
generated
Normal file
6699
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
32
package.json
Normal file
32
package.json
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"private": true,
|
||||
"name": "ninelands-game",
|
||||
"version": "0.0.0",
|
||||
"license": "BSD-3-Clause",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "npm run build:client -- --metafile=dist/client.meta.json --minify && npm run build:server -- --metafile=dist/server.meta.json --minify",
|
||||
"build:client": "esbuild --bundle --log-level=warning --sourcemap=linked --target=es2023 --format=esm --outdir=public --platform=browser src/client/splash.ts src/client/game.ts",
|
||||
"build:server": "esbuild --bundle --log-level=warning --sourcemap=linked --target=es2023 --format=cjs --outdir=dist/server --platform=node src/server/index.ts",
|
||||
"clean": "rm -rf dist/ public/*.js*",
|
||||
"format": "npm run lint -- --fix",
|
||||
"lint": "biome check --error-on-warnings",
|
||||
"playtest": "devvit playtest",
|
||||
"test": "npm run test:types && npm run lint && npm run test:unit && npm run build",
|
||||
"test:types": "tsc --build",
|
||||
"test:unit": "node --experimental-strip-types --no-warnings=ExperimentalWarning --test src/**/*.test.ts",
|
||||
"publish": "npm run clean && npm run build && devvit publish",
|
||||
"watch": "sh -c 'trap \"kill 0\" exit; npm run build:client -- --watch=forever& npm run build:server -- --watch=forever& wait' --"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.6.6",
|
||||
"@devvit/web": "0.15.3",
|
||||
"@types/node": "24.19.2",
|
||||
"devvit": "0.15.3",
|
||||
"esbuild": "0.28.2",
|
||||
"typescript": "7.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
}
|
||||
}
|
||||
37
public/game.html
Normal file
37
public/game.html
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, maximum-scale=1, minimum-scale=1, user-scalable=no"
|
||||
>
|
||||
<title>Game</title>
|
||||
<link rel="modulepreload" href="game.js">
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
width: 100dvw;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
main {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
justify-items: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="counter">
|
||||
<button id="dec-btn" type="button">-</button>
|
||||
<output id="counter">0</output>
|
||||
<button id="inc-btn" type="button">+</button>
|
||||
</div>
|
||||
</main>
|
||||
<script src="game.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
public/snoo.png
Normal file
BIN
public/snoo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
31
public/splash.html
Normal file
31
public/splash.html
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Splash</title>
|
||||
<link rel="modulepreload" href="splash.js">
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
width: 100dvw;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
main {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
justify-items: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<img src="/snoo.png" width="201" height="200" alt="Snoo">
|
||||
<button id="start-btn" type="button">Start</button>
|
||||
</main>
|
||||
<script src="splash.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
28
readme.md
Normal file
28
readme.md
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
## Devvit Bare Template
|
||||
|
||||
A practical [Devvit](https://developers.reddit.com/) app template with few dependencies. A little simpler at the expense of a little code.
|
||||
|
||||
## Getting Started
|
||||
|
||||
> Make sure you have Node 22 downloaded on your machine before running!
|
||||
|
||||
1. Run `npm create devvit@latest --template=bare`
|
||||
2. Go through the installation wizard. You will need to create a Reddit account and connect it to Reddit Developers.
|
||||
|
||||
## Commands
|
||||
|
||||
- `npm run playtest [r/sub]`: watches changes, builds, uploads, and installs on Reddit. Accepts an optional subreddit.
|
||||
- `npm run build`: builds client and server, including esbuild metafiles.
|
||||
- `npm run clean`: removes build outputs.
|
||||
- `npm run test`: runs all tests.
|
||||
- `npm run format`: fixes lints and formatting.
|
||||
- `npm run lint`: checks lints and formatting.
|
||||
- `npm run publish`: cleans, builds, uploads, and files a new app review request.
|
||||
|
||||
## Features
|
||||
|
||||
- A plain Node.js server with front and backend typing.
|
||||
- Tests using the builtin Node.js test runner.
|
||||
- Promise misuse linter.
|
||||
- Formatter and bundler.
|
||||
- TypeScript project skeleton split by environment (frontend, backend, test, etc).
|
||||
58
src/client/fetch.ts
Normal file
58
src/client/fetch.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import {
|
||||
Endpoint,
|
||||
type GetCounterRsp,
|
||||
type IncCounterReq,
|
||||
type IncCounterRsp,
|
||||
} from '../shared/api.ts'
|
||||
|
||||
export async function fetchGetCounter(): Promise<GetCounterRsp | undefined> {
|
||||
let rsp
|
||||
try {
|
||||
rsp = await fetch(Endpoint.GetCounter, {
|
||||
headers: {Accept: 'application/json'},
|
||||
})
|
||||
} catch (err) {
|
||||
const msg = `HTTP error: ${err instanceof Error ? err.message : err}`
|
||||
console.error(msg)
|
||||
return
|
||||
}
|
||||
|
||||
if (!rsp.ok) {
|
||||
const text = await rsp.text().catch(() => '')
|
||||
const err = `HTTP status ${rsp.status}: ${rsp.statusText}; ${text}`
|
||||
console.error(err)
|
||||
return
|
||||
}
|
||||
|
||||
return (await rsp.json()) as GetCounterRsp
|
||||
}
|
||||
|
||||
export async function fetchIncCounter(
|
||||
amount: number,
|
||||
): Promise<IncCounterRsp | undefined> {
|
||||
const req: IncCounterReq = {amount}
|
||||
let rsp
|
||||
try {
|
||||
rsp = await fetch(Endpoint.IncCounter, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'POST',
|
||||
body: JSON.stringify(req),
|
||||
})
|
||||
} catch (err) {
|
||||
const msg = `HTTP error: ${err instanceof Error ? err.message : err}`
|
||||
console.error(msg)
|
||||
return
|
||||
}
|
||||
|
||||
if (!rsp.ok) {
|
||||
const text = await rsp.text().catch(() => '')
|
||||
const err = `HTTP status ${rsp.status}: ${rsp.statusText}; ${text}`
|
||||
console.error(err)
|
||||
return
|
||||
}
|
||||
|
||||
return (await rsp.json()) as IncCounterRsp
|
||||
}
|
||||
23
src/client/game.ts
Normal file
23
src/client/game.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import {fetchGetCounter, fetchIncCounter} from './fetch.ts'
|
||||
|
||||
async function init(): Promise<void> {
|
||||
const counter = document.getElementById('counter') as HTMLOutputElement
|
||||
const incBtn = document.getElementById('inc-btn') as HTMLButtonElement
|
||||
const decBtn = document.getElementById('dec-btn') as HTMLButtonElement
|
||||
|
||||
incBtn.addEventListener('click', () => void incCount(counter, 1))
|
||||
decBtn.addEventListener('click', () => void incCount(counter, -1))
|
||||
|
||||
const rsp = await fetchGetCounter()
|
||||
counter.value = rsp ? `${rsp.count}` : 'Error'
|
||||
}
|
||||
|
||||
async function incCount(
|
||||
counter: HTMLOutputElement,
|
||||
amount: number,
|
||||
): Promise<void> {
|
||||
const inc = await fetchIncCounter(amount)
|
||||
counter.value = inc ? `${inc.count}` : 'Error'
|
||||
}
|
||||
|
||||
void init()
|
||||
4
src/client/splash.ts
Normal file
4
src/client/splash.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import {requestExpandedMode} from '@devvit/web/client'
|
||||
|
||||
const startBtn = document.getElementById('start-btn') as HTMLButtonElement
|
||||
startBtn.addEventListener('click', ev => requestExpandedMode(ev, 'game'))
|
||||
9
src/client/tsconfig.json
Normal file
9
src/client/tsconfig.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// TypeScript config for all client code.
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["DOM", "ES2023"],
|
||||
"customConditions": ["browser"]
|
||||
},
|
||||
"references": [{"path": "../shared/"}]
|
||||
}
|
||||
14
src/server/db.ts
Normal file
14
src/server/db.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import {redis} from '@devvit/web/server'
|
||||
import type {T3} from '@devvit/web/shared'
|
||||
|
||||
export async function dbGetCounter(t3: T3): Promise<number> {
|
||||
return Number((await redis.get(counterKey(t3))) ?? 0)
|
||||
}
|
||||
|
||||
export async function dbIncCounter(t3: T3, amount: number): Promise<number> {
|
||||
return redis.incrBy(counterKey(t3), amount)
|
||||
}
|
||||
|
||||
function counterKey(t3: T3): string {
|
||||
return `count:${t3}`
|
||||
}
|
||||
9
src/server/index.ts
Normal file
9
src/server/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
#!/usr/bin/env -S node --experimental-strip-types --no-warnings=ExperimentalWarning
|
||||
import {createServer, getServerPort} from '@devvit/web/server'
|
||||
import {onReq} from './server.ts'
|
||||
|
||||
const server = createServer(onReq)
|
||||
const port: number = getServerPort()
|
||||
|
||||
server.on('error', err => console.error(`server error; ${err.stack}`))
|
||||
server.listen(port, () => console.log(`http://localhost:${port}`))
|
||||
93
src/server/server.test.ts
Normal file
93
src/server/server.test.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
import assert from 'node:assert/strict'
|
||||
import {createServer} from 'node:http'
|
||||
import type {AddressInfo, Server} from 'node:net'
|
||||
import {after, before, beforeEach, test} from 'node:test'
|
||||
import {type Context, redis, runWithContext} from '@devvit/web/server'
|
||||
import {
|
||||
Endpoint,
|
||||
type ErrorRsp,
|
||||
type GetCounterRsp,
|
||||
type IncCounterReq,
|
||||
type IncCounterRsp,
|
||||
} from '../shared/api.ts'
|
||||
import {onReq} from './server.ts'
|
||||
|
||||
let server: Server
|
||||
let serverURL: string
|
||||
const redisValues = new Map<string, number>()
|
||||
const redisGet = redis.get.bind(redis)
|
||||
const redisIncrBy = redis.incrBy.bind(redis)
|
||||
|
||||
before(async () => {
|
||||
redis.get = async key => `${redisValues.get(key) ?? 0}`
|
||||
redis.incrBy = async (key, amount) => {
|
||||
const value = (redisValues.get(key) ?? 0) + amount
|
||||
redisValues.set(key, value)
|
||||
return value
|
||||
}
|
||||
|
||||
server = createServer(async (req, rsp) => {
|
||||
await runWithContext(
|
||||
{
|
||||
appName: 'ninelands-game',
|
||||
postId: 't3_123',
|
||||
userId: 't2_123',
|
||||
username: 'username',
|
||||
} as unknown as Context,
|
||||
() => onReq(req, rsp),
|
||||
)
|
||||
})
|
||||
await new Promise<void>(resolve => {
|
||||
server.listen(0, '127.0.0.1', () => resolve())
|
||||
})
|
||||
const info = server.address() as AddressInfo
|
||||
serverURL = `http://127.0.0.1:${info.port}`
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
redis.get = redisGet
|
||||
redis.incrBy = redisIncrBy
|
||||
if (!server.listening) return
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close(err => (err ? reject(err) : resolve()))
|
||||
})
|
||||
})
|
||||
|
||||
beforeEach(() => redisValues.clear())
|
||||
|
||||
test('get counter', async () => {
|
||||
const rsp = await fetch(`${serverURL}/${Endpoint.GetCounter}`)
|
||||
assert.equal(rsp.status, 200)
|
||||
assert.equal(rsp.headers.get('Content-Type'), 'application/json')
|
||||
assert.deepEqual<GetCounterRsp>(await rsp.json(), {count: 0})
|
||||
})
|
||||
|
||||
test('inc', async () => {
|
||||
const req: IncCounterReq = {amount: 1}
|
||||
const rsp = await fetch(`${serverURL}/${Endpoint.IncCounter}`, {
|
||||
body: JSON.stringify(req),
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
method: 'POST',
|
||||
})
|
||||
assert.equal(rsp.status, 200)
|
||||
assert.equal(rsp.headers.get('Content-Type'), 'application/json')
|
||||
assert.deepEqual<IncCounterRsp>(await rsp.json(), {count: 1})
|
||||
})
|
||||
|
||||
test('wrong method', async () => {
|
||||
const rsp = await fetch(`${serverURL}/${Endpoint.IncCounter}`)
|
||||
assert.equal(rsp.status, 404)
|
||||
assert.deepEqual<ErrorRsp>(await rsp.json(), {
|
||||
error: 'not found',
|
||||
status: 404,
|
||||
})
|
||||
})
|
||||
|
||||
test('404', async () => {
|
||||
const rsp = await fetch(serverURL)
|
||||
assert.equal(rsp.status, 404)
|
||||
assert.deepEqual<ErrorRsp>(await rsp.json(), {
|
||||
error: 'not found',
|
||||
status: 404,
|
||||
})
|
||||
})
|
||||
118
src/server/server.ts
Normal file
118
src/server/server.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import {once} from 'node:events'
|
||||
import type {IncomingMessage, ServerResponse} from 'node:http'
|
||||
import {context, reddit} from '@devvit/web/server'
|
||||
import type {
|
||||
PartialJsonValue,
|
||||
TriggerResponse,
|
||||
UiResponse,
|
||||
} from '@devvit/web/shared'
|
||||
import {
|
||||
Endpoint,
|
||||
EndpointMethod,
|
||||
type ErrorRsp,
|
||||
type GetCounterRsp,
|
||||
type IncCounterReq,
|
||||
type IncCounterRsp,
|
||||
} from '../shared/api.ts'
|
||||
import {dbGetCounter, dbIncCounter} from './db.ts'
|
||||
|
||||
type AnyRsp =
|
||||
| GetCounterRsp
|
||||
| IncCounterRsp
|
||||
| UiResponse
|
||||
| TriggerResponse
|
||||
| ErrorRsp
|
||||
|
||||
export async function onReq(
|
||||
reqMsg: IncomingMessage,
|
||||
rspMsg: ServerResponse,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await route(reqMsg, rspMsg)
|
||||
} catch (err) {
|
||||
const msg = `server error; ${err instanceof Error ? err.stack : err}`
|
||||
console.error(msg)
|
||||
writeJson<ErrorRsp>(500, {error: msg, status: 500}, rspMsg)
|
||||
}
|
||||
}
|
||||
|
||||
async function route(
|
||||
reqMsg: IncomingMessage,
|
||||
rspMsg: ServerResponse,
|
||||
): Promise<void> {
|
||||
const endpoint = reqMsg.url?.slice(1) as Endpoint
|
||||
const method = EndpointMethod[endpoint]
|
||||
|
||||
let rsp: AnyRsp
|
||||
if (method !== reqMsg.method) {
|
||||
rsp = {error: 'not found', status: 404}
|
||||
} else {
|
||||
switch (endpoint) {
|
||||
case Endpoint.GetCounter:
|
||||
rsp = await routeGetCounter()
|
||||
break
|
||||
case Endpoint.IncCounter:
|
||||
rsp = await routeInc(reqMsg)
|
||||
break
|
||||
case Endpoint.OnMenuNewPost:
|
||||
rsp = await routeMenuNewPost()
|
||||
break
|
||||
case Endpoint.OnAppInstall:
|
||||
rsp = await routeAppInstall()
|
||||
break
|
||||
default:
|
||||
endpoint satisfies never
|
||||
rsp = {error: 'not found', status: 404}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
writeJson<PartialJsonValue>('status' in rsp ? rsp.status : 200, rsp, rspMsg)
|
||||
}
|
||||
|
||||
async function routeGetCounter(): Promise<GetCounterRsp> {
|
||||
const t3 = context.postId
|
||||
if (!t3) throw Error('no t3')
|
||||
return {count: await dbGetCounter(t3)}
|
||||
}
|
||||
|
||||
async function routeInc(reqMsg: IncomingMessage): Promise<IncCounterRsp> {
|
||||
const t3 = context.postId
|
||||
if (!t3) throw Error('no t3')
|
||||
const req = await readJson<IncCounterReq>(reqMsg)
|
||||
return {count: await dbIncCounter(t3, req.amount)}
|
||||
}
|
||||
|
||||
async function routeMenuNewPost(): Promise<UiResponse> {
|
||||
const post = await reddit.submitCustomPost({title: context.appSlug})
|
||||
return {
|
||||
showToast: {text: `Post ${post.id} created.`, appearance: 'success'},
|
||||
navigateTo: post.url,
|
||||
}
|
||||
}
|
||||
|
||||
async function routeAppInstall(): Promise<TriggerResponse> {
|
||||
await reddit.submitCustomPost({title: context.appSlug})
|
||||
return {}
|
||||
}
|
||||
|
||||
async function readJson<T>(reqMsg: IncomingMessage): Promise<T> {
|
||||
const chunks: Uint8Array[] = []
|
||||
reqMsg.on('data', chunk => chunks.push(chunk))
|
||||
await once(reqMsg, 'end')
|
||||
return JSON.parse(`${Buffer.concat(chunks)}`)
|
||||
}
|
||||
|
||||
function writeJson<T extends PartialJsonValue>(
|
||||
status: number,
|
||||
json: Readonly<T>,
|
||||
rsp: ServerResponse,
|
||||
): void {
|
||||
const body = JSON.stringify(json)
|
||||
const len = Buffer.byteLength(body)
|
||||
rsp.writeHead(status, {
|
||||
'Content-Length': len,
|
||||
'Content-Type': 'application/json',
|
||||
})
|
||||
rsp.end(body)
|
||||
}
|
||||
9
src/server/tsconfig.json
Normal file
9
src/server/tsconfig.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// TypeScript config for all server code.
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"types": ["node"],
|
||||
"customConditions": ["server"]
|
||||
},
|
||||
"references": [{"path": "../shared/"}]
|
||||
}
|
||||
24
src/shared/api.ts
Normal file
24
src/shared/api.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
/** Generic error detail for all responses. */
|
||||
export type ErrorRsp = {error: string; status: number}
|
||||
|
||||
/** The current counter state for this post. */
|
||||
export type GetCounterRsp = {count: number}
|
||||
|
||||
/** Increment the post counter by a signed amount. */
|
||||
export type IncCounterReq = {amount: number}
|
||||
export type IncCounterRsp = {count: number}
|
||||
|
||||
export type Endpoint = (typeof Endpoint)[keyof typeof Endpoint]
|
||||
export const Endpoint = {
|
||||
GetCounter: 'api/counter',
|
||||
IncCounter: 'api/counter/inc',
|
||||
OnAppInstall: 'internal/on/app/install',
|
||||
OnMenuNewPost: 'internal/on/menu/new-post',
|
||||
} as const
|
||||
|
||||
export const EndpointMethod = {
|
||||
[Endpoint.GetCounter]: 'GET',
|
||||
[Endpoint.IncCounter]: 'POST',
|
||||
[Endpoint.OnAppInstall]: 'POST',
|
||||
[Endpoint.OnMenuNewPost]: 'POST',
|
||||
} as const satisfies {[endpoint: string]: 'GET' | 'POST'}
|
||||
5
src/shared/tsconfig.json
Normal file
5
src/shared/tsconfig.json
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// TypeScript config for isomorphic code.
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {"lib": ["WebWorker", "ES2023"]}
|
||||
}
|
||||
15
src/test/tsconfig.json
Normal file
15
src/test/tsconfig.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
// TypeScript config for test code.
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["DOM", "ES2023"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["../**/*.test.*", "../**/test/", "../**/test/**/*.json"],
|
||||
"exclude": ["${configDir}/node_modules/", "${configDir}/dist/"],
|
||||
"references": [
|
||||
{"path": "../client/"},
|
||||
{"path": "../server/"},
|
||||
{"path": "../shared/"}
|
||||
]
|
||||
}
|
||||
24
tsconfig.base.json
Normal file
24
tsconfig.base.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"target": "ES2023",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"isolatedModules": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"rootDir": ".",
|
||||
"outDir": "./dist/web/",
|
||||
"skipLibCheck": true,
|
||||
"lib": ["ES2023", "ESNext.Disposable"]
|
||||
},
|
||||
// Exclude colocated tests and test utils.
|
||||
"exclude": [
|
||||
"${configDir}/node_modules/",
|
||||
"${configDir}/dist/",
|
||||
"${configDir}/**/*.test.*",
|
||||
"${configDir}/**/test/"
|
||||
]
|
||||
}
|
||||
10
tsconfig.json
Normal file
10
tsconfig.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
// Only build references.
|
||||
"files": [],
|
||||
"references": [
|
||||
{"path": "./src/client/"},
|
||||
{"path": "./src/server/"},
|
||||
{"path": "./src/shared/"},
|
||||
{"path": "./src/test/"}
|
||||
]
|
||||
}
|
||||
Loading…
Reference in a new issue