diff --git a/PLAN.md b/PLAN.md index c0bf9d7..94e9823 100644 --- a/PLAN.md +++ b/PLAN.md @@ -226,3 +226,16 @@ Abgabe: **9. Dezember 2026, 03:00 MEZ**. Eingereicht wird in „Best Daily Game `win` = mindestens ein Stern, `score` = Gold. Alles „feuern und vergessen“. - Reddit leitet Ereignisse erst nach Freischaltung in der App-Review weiter (Allowlist); bis dahin „dropped“. - Katalog (Besitzer): App-Icon aus dem Hauptspiel, Texte wie vorgeschlagen, Kategorie Game → Strategy. + +### 2026-11-13 – README und Löschregel + +- **`README.md`** (Pflicht laut Jam und Devvit-Regeln, Großschreibung): Überblick, Spielanleitung, Sterne/Bestenliste/ + Streak, Einrichtung für Moderatoren, Datenschutz, Entwicklung, Credits, Lizenz. Die Vorlage hieß `readme.md`. +- **Devvit-Regel „Enable and respect user deletions“:** Für Kontolöschungen gibt es keinen Trigger. Deshalb laufen + alle Schlüssel mit User-ID oder Namen nach **30 Tagen** ab (`RETENTION_S`), jede Abgabe verlängert die Frist. + Spielerdaten liegen jetzt je Spieler unter `player:{userId}` statt im Hash `players` (einzelne Hash-Felder können + nicht ablaufen). Folge: Bestenlisten älterer Posts sind nach 30 Tagen leer; wer 30 Tage nicht spielt, verliert + Statistik und Freischaltungen (Phase 2 – beim Entwurf der Freischaltungen bedenken). Schutztest in + `server.test.ts`. +- **Playtest-Fallstrick:** Mehrere Änderungen in kurzer Folge (vor allem an `devvit.json`) lösten zwei Uploads mit + derselben Versionsnummer aus („This AppVersion already exists“), `devvit playtest` brach ab → neu starten. diff --git a/README.md b/README.md new file mode 100644 index 0000000..baedf9c --- /dev/null +++ b/README.md @@ -0,0 +1,99 @@ +# Ninelands – a daily strategy puzzle for Reddit + +**Ninelands** is a bite-sized strategy game that lives inside a Reddit post. Every day brings a fresh 9×9 map that is +the same for everyone. Build your realm piece by piece, claim lands and gold, chase three stars and climb today's +leaderboard – then compare your realm with the community in the comments. + +It is a port of [Ninelands](https://ninelands.stackteam.at), a strategy board game by StackTeam that runs on the web, +desktop, Android and itch.io. The Reddit edition is a new, focused daily mode built on Devvit Web for the +_Reddit Game of the Day 2026_ hackathon. + +## How to play + +1. **Open today's post** and press **Play**. +2. **Place your castle.** It is free and founds your realm. It claims land in straight lines (up to 5 cells) – + nothing can be built on claimed land afterwards. +3. **Grow your realm.** Every new piece must connect to the land you already hold: + + | Piece | Cost | Claims | Rules | + | :--------- | :--: | :------------------------------------------------ | :--------------------------------------------------- | + | **Castle** | free | straight lines, reach 5, crosses borders | only one, placed first, not in the moor | + | **Farmer** | free | the 8 surrounding cells in its own land | must border your land directly, in the same land | + | **Knight** | 10 | straight lines, reach 2, stops at the land border | must touch your land in the same land · **2× yield** | + | **Bishop** | 15 | diagonal lines, reach 2, crosses borders | must touch your land | + +4. **Earn gold.** Ore (mountains) and herbs (moor) pay 20 gold, every claimed pasture cell 5 (7 in a land with a lake). + Knights pay double. Building costs are deducted. +5. **Rule lands.** The board is split into 9 lands; you rule a land once you claim at least half of it. +6. **Finish.** The game ends when no move is left – or whenever you press **End game** (tap twice). If no remaining + move earns gold, the game suggests ending. + +Every map has one **law of the realm** that changes the rules a little (e.g. _Costly knights_, _One farm per land_). +Hover or tap a cell to preview what a piece would claim and how much gold it brings; the line below the board +explains why a cell does or doesn't fit. + +### Stars, leaderboard and streaks + +- **Three stars per map:** reach the gold target, rule the lands target, and both in the same game. Targets are + computed from computer games on that very map, so every day and every law is fair. +- **Only your first game counts.** You may play again for practice; it won't change your result. +- **Leaderboard:** more stars first, then more gold; on a tie the earlier submission wins. The server replays every + submitted game with the same engine and computes gold and stars itself – results cannot be faked. +- **Streak:** play the map of the day on consecutive days. Older posts can still be played, but only today's map + keeps your streak alive. +- **Share** your result line (`Ninelands #12 ★★☆ · 8/9 lands · 85 gold`) and discuss strategies in the comments. + +## Features + +- A new post every day at 00:01 UTC (Devvit scheduler) with its own map, leaderboard and comment thread. +- Inline start screen in the feed (map preview, law of the day, goals or your result, streak, top 3) and the full + board in expanded mode. +- Mobile first: tap once to preview, tap again to place. Music starts only after your first tap and can be muted. +- Devvit Journeys: app ready, journey start when the castle is placed, a progress event per star, end with gold as + score. + +## For moderators: setup + +1. **Install the app** in your subreddit from its page on developers.reddit.com. +2. **That's it.** On installation the app creates today's post. After that it posts a new map automatically every day + at 00:01 UTC – no manual work needed. +3. If a daily post is ever missing, open the subreddit's mod menu (⋯) and choose + **“Ninelands: create today's post”**. It never creates a duplicate. + +Each subreddit has its own leaderboards and streaks. There is nothing to configure. + +## Privacy + +The app stores only what the game needs: your Reddit user ID and name with your first result per day (for the +leaderboard) and your streak statistics. All of it expires automatically **30 days** after your last game. No data +leaves Reddit. + +## Development + +Built from the Devvit template _Bare_: TypeScript, esbuild, Biome, `node --test`. + +```sh +npm install +npm run test # types, lint, unit tests, build +npm run playtest # build, upload and install in the test subreddit +npm run capture # record the catalog assets (video, GIF, stills) into dist/marketing/ +``` + +- `src/client/` – start screen (`splash`) and game (`game`) +- `src/server/` – endpoints: day state, verified submission, leaderboard, streak, daily post, journeys +- `src/shared/` – API types and the daily map (seed from the UTC date) +- `src/engine/` – the Ninelands engine and board renderer, copied unchanged from a tagged release by + `scripts/sync-engine.sh` (see `src/engine/VERSION`) + +## Credits + +- Music: “Dreamscape (part I)” by Vee Ray – CC BY-SA 4.0 (re-encoded) +- Graphics: “Mini World Sprites” by Shade and octoshrimpy – CC0 +- Font: Pixelify Sans – SIL Open Font License 1.1 +- Project skeleton: Devvit template “Bare” © Reddit Inc. – BSD-3-Clause + +Details in [CREDITS.md](CREDITS.md). + +## License + +GPL-3.0 – see [LICENSE](LICENSE). The template parts remain under BSD-3-Clause ([LICENSE-TEMPLATE.md](LICENSE-TEMPLATE.md)). diff --git a/readme.md b/readme.md deleted file mode 100644 index b5349f0..0000000 --- a/readme.md +++ /dev/null @@ -1,28 +0,0 @@ -## 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). diff --git a/src/server/db.ts b/src/server/db.ts index 689baa5..6691fcc 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -4,8 +4,12 @@ // results:{day} Hash userId → JSON des ersten Versuchs (hSetNX = nur der erste zählt) // board:{day} Sorted Set userId → rankScore (Bestenliste) // seq:{day} Zähler der Abgaben (Gleichstand: frühere Abgabe gewinnt) -// players Hash userId → JSON PlayerStats + lastDay +// player:{userId} JSON PlayerStats + lastDay // posts Hash day → postId (verhindert doppelte Tagesposts) +// +// Devvit-Regel „Enable and respect user deletions“: Für Kontolöschungen gibt es keinen Trigger, deshalb laufen alle +// Schlüssel mit User-ID oder Namen nach RETENTION_S ab (Reddit empfiehlt ≤ 30 Tage). Jede Abgabe verlängert die +// Frist der betroffenen Schlüssel; wer 30 Tage nicht spielt, ist danach vollständig gelöscht. import {redis} from '@devvit/web/server' import type {DayResult, Goals, LeaderRow, PlayerStats} from '../shared/api.ts' import {addDays} from '../shared/daily.ts' @@ -22,6 +26,9 @@ type StoredPlayer = PlayerStats & {lastDay: string} const TOP = 10 +/** Aufbewahrung von Nutzerdaten: 30 Tage */ +export const RETENTION_S = 30 * 24 * 60 * 60 + export async function dbGetGoals(day: string): Promise { const raw = await redis.get(`goals:${day}`) return raw ? (JSON.parse(raw) as Goals) : undefined @@ -29,6 +36,7 @@ export async function dbGetGoals(day: string): Promise { export async function dbSetGoals(day: string, goals: Goals): Promise { await redis.set(`goals:${day}`, JSON.stringify(goals)) + await redis.expire(`goals:${day}`, RETENTION_S) } /** @@ -52,6 +60,11 @@ export async function dbSaveFirstResult( member: userId, score: rankScore(result.stars, result.gold, seq), }) + await Promise.all( + [`results:${day}`, `board:${day}`, `seq:${day}`].map(k => + redis.expire(k, RETENTION_S), + ), + ) return true } @@ -112,7 +125,7 @@ const EMPTY_STATS: StoredPlayer = { } async function loadPlayer(userId: string): Promise { - const raw = await redis.hGet('players', userId) + const raw = await redis.get(`player:${userId}`) return raw ? {...EMPTY_STATS, ...(JSON.parse(raw) as StoredPlayer)} : {...EMPTY_STATS} @@ -154,7 +167,8 @@ export async function dbRecordPlay( p.bestStreak = Math.max(p.bestStreak, p.streak) p.lastDay = today } - await redis.hSet('players', {[userId]: JSON.stringify(p)}) + await redis.set(`player:${userId}`, JSON.stringify(p)) + await redis.expire(`player:${userId}`, RETENTION_S) return visibleStats(p, today) } diff --git a/src/server/server.test.ts b/src/server/server.test.ts index c37c0af..0bf2aaa 100644 --- a/src/server/server.test.ts +++ b/src/server/server.test.ts @@ -41,7 +41,12 @@ const sortedAsc = (k: string) => [...zset(k).entries()].sort((a, b) => a[1] - b[1]) const original = {...redis} +/** Ablaufzeiten, die db.ts gesetzt hat (Schlüssel → Sekunden) */ +const expiries = new Map() const fake: Partial = { + expire: async (k, seconds) => { + expiries.set(k, seconds) + }, get: async k => strings.get(k), set: async (k, v) => { strings.set(k, v) @@ -112,6 +117,7 @@ const today = dailyMap(new Date()).day beforeEach(() => { strings.clear() + expiries.clear() hashes.clear() zsets.clear() ctx = {userId: 't2_alice', username: 'alice', postData: {day: today}} @@ -164,6 +170,18 @@ test('Abgabe wird nachgespielt, gewertet und zählt die Streak', async () => { assert.equal(body.top[0]?.name, 'alice') }) +test('Nutzerdaten laufen nach 30 Tagen ab (Devvit-Regel zu Löschungen)', async () => { + await submit({day: today, moves: botMoves(today, 1)}) + const month = 30 * 24 * 60 * 60 + for (const k of [ + `results:${today}`, + `board:${today}`, + `seq:${today}`, + 'player:t2_alice', + ]) + assert.equal(expiries.get(k), month, k) +}) + test('nur der erste Versuch zählt', async () => { await submit({day: today, moves: botMoves(today, 1)}) const second = (await ( @@ -176,8 +194,8 @@ test('nur der erste Versuch zählt', async () => { test('Streak wächst an Folgetagen und reißt nach einer Lücke', async () => { const stored = (lastDay: string, streak: number) => - hash('players').set( - 't2_alice', + strings.set( + 'player:t2_alice', JSON.stringify({ streak, bestStreak: streak,