Merge branch 'docs/readme'
All checks were successful
CI / test (push) Successful in 24s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lukas Müllner 2026-11-13 17:26:36 +01:00
commit 90067bc2e1
5 changed files with 149 additions and 33 deletions

13
PLAN.md
View file

@ -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.

99
README.md Normal file
View file

@ -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)).

View file

@ -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).

View file

@ -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<Goals | undefined> {
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<Goals | undefined> {
export async function dbSetGoals(day: string, goals: Goals): Promise<void> {
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<StoredPlayer> {
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)
}

View file

@ -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<string, number>()
const fake: Partial<typeof redis> = {
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,