Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
90067bc2e1
5 changed files with 149 additions and 33 deletions
13
PLAN.md
13
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.
|
||||
|
|
|
|||
99
README.md
Normal file
99
README.md
Normal 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)).
|
||||
28
readme.md
28
readme.md
|
|
@ -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).
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue