diff --git a/PLAN.md b/PLAN.md index 123af46..348b52d 100644 --- a/PLAN.md +++ b/PLAN.md @@ -142,3 +142,23 @@ Abgabe: **9. Dezember 2026, 03:00 MEZ**. Eingereicht wird in „Best Daily Game kodiert (1,0 statt 1,6 MB; Besitzer hat Varianten verglichen). MP3 läuft auch in iOS-Webviews. Ein Größenlimit für Webview-Assets steht nicht in der Doku; weitere Titel in `scripts/sync-engine.sh` (`musik=(…)`) ergänzen. - Biome: Ordner-Ausschluss als `"!src/engine"` schreiben – `"!src/engine/"` mit Schrägstrich griff nicht. + +### 2026-11-13 – Phase 1, Server + +- Endpunkte: `GET api/day` (Karte, Ziele, eigenes Ergebnis, Streak, Top 10, Spielerzahl) und `POST api/submit` + (Züge). Der Tag eines Posts steht in den Post-Daten (`postData.day`); ohne Daten gilt heute (UTC). +- **Prüfung:** Der Server spielt die Züge nach (`src/server/verify.ts`) und wertet nur **fertige** Partien (kein Zug + mehr möglich) – sonst ließe sich früh abbrechen. Gold, Länder und Sterne rechnet er selbst; der Client schickt nur + Züge. +- **Erster Versuch:** `hSetNX` auf `results:{day}` ist atomar; ein zweiter Versuch liefert `counted: false` und das + alte Ergebnis. +- **Bestenliste:** Sorted Set mit `Sterne·100 000 + Gold + 1/(seq+1)` – `seq` ist die laufende Nummer der Abgabe, + dadurch gewinnt bei Gleichstand die frühere, und die Werte sind eindeutig (Platz = `zCard − zRank`). +- **Streak** zählt nur, wenn der Post von heute gespielt wird; alte Posts kann man nachspielen (Bestenliste ja, + Streak nein). Angezeigt wird 0, wenn der letzte Tag älter als gestern ist. +- **Tagespost:** Scheduler `1 0 * * *` (00:01 UTC), dazu bei Installation und per Moderator-Menü. Hash `posts` + verhindert doppelte Posts. +- `devvit.json`: **`redis: true` muss gesetzt sein** (Standard ist `false`). Startbildschirm inline (`height: tall`), + Spiel als Entry `game` (Expanded). +- **Fallstrick:** `hMGet` ist laut Doku nicht für jede App freigeschaltet → einzelne `hGet`. +- Offen: Löschungen von Nutzern respektieren (Devvit-Regel) – Ergebnis und Name bei Kontolöschung entfernen. diff --git a/devvit.json b/devvit.json index b79ea40..951b3f2 100644 --- a/devvit.json +++ b/devvit.json @@ -4,7 +4,9 @@ "post": { "entrypoints": { "default": { - "entry": "splash.html" + "entry": "splash.html", + "height": "tall", + "inline": true }, "game": { "entry": "game.html" @@ -12,16 +14,28 @@ } }, "server": {}, + "permissions": { + "redis": true + }, "menu": { "items": [ { - "label": "[ninelands-game] New Post", - "description": "Create a new ninelands-game post.", + "label": "Ninelands: create today's post", + "description": "Creates today's daily map post if it does not exist yet.", "location": "subreddit", + "forUserType": "moderator", "endpoint": "/internal/on/menu/new-post" } ] }, + "scheduler": { + "tasks": { + "daily-post": { + "endpoint": "/internal/scheduler/daily-post", + "cron": "1 0 * * *" + } + } + }, "triggers": { "onAppInstall": "/internal/on/app/install" }, diff --git a/src/client/fetch.ts b/src/client/fetch.ts deleted file mode 100644 index c2d2d07..0000000 --- a/src/client/fetch.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { - Endpoint, - type GetCounterRsp, - type IncCounterReq, - type IncCounterRsp, -} from '../shared/api.ts' - -export async function fetchGetCounter(): Promise { - 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 { - 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 -} diff --git a/src/client/game.ts b/src/client/game.ts index 1117090..e37f168 100644 --- a/src/client/game.ts +++ b/src/client/game.ts @@ -1,23 +1,2 @@ -import {fetchGetCounter, fetchIncCounter} from './fetch.ts' - -async function init(): Promise { - 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 { - const inc = await fetchIncCounter(amount) - counter.value = inc ? `${inc.count}` : 'Error' -} - -void init() +// Platzhalter – das Spiel folgt im nächsten Schritt von Phase 1 +export {} diff --git a/src/server/db.ts b/src/server/db.ts index f1c21a2..689baa5 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -1,14 +1,167 @@ +// Speicher in Redis (je Subreddit-Installation getrennt). +// Schlüssel: +// goals:{day} Ziele der Tageskarte als JSON (Cache, berechnet beim ersten Abruf) +// 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 +// posts Hash day → postId (verhindert doppelte Tagesposts) import {redis} from '@devvit/web/server' -import type {T3} from '@devvit/web/shared' +import type {DayResult, Goals, LeaderRow, PlayerStats} from '../shared/api.ts' +import {addDays} from '../shared/daily.ts' +import {rankScore} from './verify.ts' -export async function dbGetCounter(t3: T3): Promise { - return Number((await redis.get(counterKey(t3))) ?? 0) +type StoredResult = { + name: string + stars: number + gold: number + lands: number + at: number +} +type StoredPlayer = PlayerStats & {lastDay: string} + +const TOP = 10 + +export async function dbGetGoals(day: string): Promise { + const raw = await redis.get(`goals:${day}`) + return raw ? (JSON.parse(raw) as Goals) : undefined } -export async function dbIncCounter(t3: T3, amount: number): Promise { - return redis.incrBy(counterKey(t3), amount) +export async function dbSetGoals(day: string, goals: Goals): Promise { + await redis.set(`goals:${day}`, JSON.stringify(goals)) } -function counterKey(t3: T3): string { - return `count:${t3}` +/** + * Ersten Versuch speichern. Liefert false, wenn es schon einen gab (dann bleibt der alte). + * hSetNX ist atomar – zwei gleichzeitige Abgaben desselben Spielers können nicht beide zählen. + */ +export async function dbSaveFirstResult( + day: string, + userId: string, + result: Omit, +): Promise { + const stored: StoredResult = {...result, at: Date.now()} + const isNew = await redis.hSetNX( + `results:${day}`, + userId, + JSON.stringify(stored), + ) + if (!isNew) return false + const seq = await redis.incrBy(`seq:${day}`, 1) + await redis.zAdd(`board:${day}`, { + member: userId, + score: rankScore(result.stars, result.gold, seq), + }) + return true +} + +export async function dbGetResult( + day: string, + userId: string, +): Promise { + const raw = await redis.hGet(`results:${day}`, userId) + if (!raw) return null + const r = JSON.parse(raw) as StoredResult + return { + stars: r.stars, + gold: r.gold, + lands: r.lands, + rank: await rankOf(day, userId), + } +} + +/** Platz (1 = beste); die Werte im Sorted Set sind eindeutig (Bruchteil aus seq) */ +async function rankOf(day: string, userId: string): Promise { + const [asc, count] = await Promise.all([ + redis.zRank(`board:${day}`, userId), + redis.zCard(`board:${day}`), + ]) + return asc === undefined ? 0 : count - asc +} + +export async function dbPlayers(day: string): Promise { + return redis.zCard(`board:${day}`) +} + +export async function dbTop(day: string): Promise { + const rows = await redis.zRange(`board:${day}`, 0, TOP - 1, { + by: 'rank', + reverse: true, + }) + if (rows.length === 0) return [] + // Einzelne hGet statt hMGet: hMGet ist laut Doku nicht für jede App freigeschaltet + const raw = await Promise.all( + rows.map(r => redis.hGet(`results:${day}`, r.member)), + ) + return rows.flatMap((_, i) => { + const json = raw[i] + if (!json) return [] + const r = JSON.parse(json) as StoredResult + return [ + {rank: i + 1, name: r.name, stars: r.stars, gold: r.gold, lands: r.lands}, + ] + }) +} + +const EMPTY_STATS: StoredPlayer = { + streak: 0, + bestStreak: 0, + played: 0, + totalStars: 0, + lastDay: '', +} + +async function loadPlayer(userId: string): Promise { + const raw = await redis.hGet('players', userId) + return raw + ? {...EMPTY_STATS, ...(JSON.parse(raw) as StoredPlayer)} + : {...EMPTY_STATS} +} + +/** Streak der Anzeige: ist der letzte gespielte Tag älter als gestern, ist sie gerissen */ +function visibleStats(p: StoredPlayer, today: string): PlayerStats { + const alive = p.lastDay === today || p.lastDay === addDays(today, -1) + return { + streak: alive ? p.streak : 0, + bestStreak: p.bestStreak, + played: p.played, + totalStars: p.totalStars, + } +} + +export async function dbGetStats( + userId: string, + today: string, +): Promise { + return visibleStats(await loadPlayer(userId), today) +} + +/** + * Gewertete Partie in die Statistik übernehmen. Die Streak zählt nur, wenn die Karte von heute gespielt wurde + * (ältere Posts kann man nachspielen, das hält die Streak aber nicht am Leben). + */ +export async function dbRecordPlay( + userId: string, + day: string, + today: string, + stars: number, +): Promise { + const p = await loadPlayer(userId) + p.played += 1 + p.totalStars += stars + if (day === today && p.lastDay !== today) { + p.streak = p.lastDay === addDays(today, -1) ? p.streak + 1 : 1 + p.bestStreak = Math.max(p.bestStreak, p.streak) + p.lastDay = today + } + await redis.hSet('players', {[userId]: JSON.stringify(p)}) + return visibleStats(p, today) +} + +export async function dbGetPostId(day: string): Promise { + return redis.hGet('posts', day) +} + +export async function dbSetPostId(day: string, postId: string): Promise { + await redis.hSet('posts', {[day]: postId}) } diff --git a/src/server/server.test.ts b/src/server/server.test.ts index 1b15bb9..0d4d7ee 100644 --- a/src/server/server.test.ts +++ b/src/server/server.test.ts @@ -3,36 +3,93 @@ 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 {BOTS} from '../engine/core/bots.ts' +import {Game} from '../engine/core/Game.ts' +import {encodeMoves} from '../engine/core/replay.ts' +import {mulberry32} from '../engine/core/rng.ts' import { Endpoint, type ErrorRsp, - type GetCounterRsp, - type IncCounterReq, - type IncCounterRsp, + type GetDayRsp, + type SubmitReq, + type SubmitRsp, } from '../shared/api.ts' +import {addDays, dailyBoard, dailyMap} from '../shared/daily.ts' import {onReq} from './server.ts' +// Redis-Attrappe im Speicher: nur die Befehle, die db.ts benutzt +const strings = new Map() +const hashes = new Map>() +const zsets = new Map>() +function hash(k: string): Map { + let h = hashes.get(k) + if (!h) { + h = new Map() + hashes.set(k, h) + } + return h +} +function zset(k: string): Map { + let z = zsets.get(k) + if (!z) { + z = new Map() + zsets.set(k, z) + } + return z +} +const sortedAsc = (k: string) => + [...zset(k).entries()].sort((a, b) => a[1] - b[1]) + +const original = {...redis} +const fake: Partial = { + get: async k => strings.get(k), + set: async (k, v) => { + strings.set(k, v) + return 'OK' + }, + incrBy: async (k, n) => { + const v = Number(strings.get(k) ?? 0) + n + strings.set(k, String(v)) + return v + }, + hGet: async (k, f) => hash(k).get(f), + hSet: async (k, values) => { + for (const [f, v] of Object.entries(values)) hash(k).set(f, v) + return Object.keys(values).length + }, + hSetNX: async (k, f, v) => { + if (hash(k).has(f)) return 0 + hash(k).set(f, v) + return 1 + }, + zAdd: async (k, ...members) => { + for (const m of members) zset(k).set(m.member, m.score) + return members.length + }, + zCard: async k => zset(k).size, + zRank: async (k, m) => { + const i = sortedAsc(k).findIndex(([member]) => member === m) + return i < 0 ? undefined : i + }, + zRange: async (k, start, stop, opts) => { + const rows = sortedAsc(k).map(([member, score]) => ({member, score})) + if (opts?.reverse) rows.reverse() + return rows.slice(Number(start), Number(stop) + 1) + }, +} + let server: Server let serverURL: string -const redisValues = new Map() -const redisGet = redis.get.bind(redis) -const redisIncrBy = redis.incrBy.bind(redis) +let ctx: {userId?: string; username?: string; postData?: {day: string}} 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 - } - + Object.assign(redis, fake) server = createServer(async (req, rsp) => { await runWithContext( { appName: 'ninelands-game', postId: 't3_123', - userId: 't2_123', - username: 'username', + ...ctx, } as unknown as Context, () => onReq(req, rsp), ) @@ -40,47 +97,148 @@ before(async () => { await new Promise(resolve => { server.listen(0, '127.0.0.1', () => resolve()) }) - const info = server.address() as AddressInfo - serverURL = `http://127.0.0.1:${info.port}` + serverURL = `http://127.0.0.1:${(server.address() as AddressInfo).port}` }) after(async () => { - redis.get = redisGet - redis.incrBy = redisIncrBy + Object.assign(redis, original) if (!server.listening) return await new Promise((resolve, reject) => { server.close(err => (err ? reject(err) : resolve())) }) }) -beforeEach(() => redisValues.clear()) +const today = dailyMap(new Date()).day -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(await rsp.json(), {count: 0}) +beforeEach(() => { + strings.clear() + hashes.clear() + zsets.clear() + ctx = {userId: 't2_alice', username: 'alice', postData: {day: today}} }) -test('inc', async () => { - const req: IncCounterReq = {amount: 1} - const rsp = await fetch(`${serverURL}/${Endpoint.IncCounter}`, { - body: JSON.stringify(req), +/** Fertige Bot-Partie auf der Karte des Tages; seed variiert das Ergebnis */ +function botMoves(day: string, seed: number): SubmitReq['moves'] { + const map = dailyMap(new Date(`${day}T00:00:00Z`)) + const game = new Game(dailyBoard(map), {undoLimit: 0}) + const rng = mulberry32(seed) + while (!game.isGameOver()) { + const m = BOTS.greedy(game, rng) + game.place(m.unit, m.cell) + } + return encodeMoves(game.moves) as SubmitReq['moves'] +} + +async function submit(body: unknown): Promise { + return fetch(`${serverURL}/${Endpoint.Submit}`, { + body: JSON.stringify(body), headers: {'Content-Type': 'application/json'}, method: 'POST', }) +} + +async function getDay(): Promise { + const rsp = await fetch(`${serverURL}/${Endpoint.GetDay}`) assert.equal(rsp.status, 200) - assert.equal(rsp.headers.get('Content-Type'), 'application/json') - assert.deepEqual(await rsp.json(), {count: 1}) + return (await rsp.json()) as GetDayRsp +} + +test('Tag laden: Karte, Ziele, noch nicht gespielt', async () => { + const day = await getDay() + assert.equal(day.day, today) + assert.equal(day.today, true) + assert.equal(day.loggedIn, true) + assert.equal(day.mine, null) + assert.equal(day.players, 0) + assert.ok(day.goals.gold >= 5) }) -test('wrong method', async () => { - const rsp = await fetch(`${serverURL}/${Endpoint.IncCounter}`) - assert.equal(rsp.status, 404) - assert.deepEqual(await rsp.json(), { - error: 'not found', - status: 404, - }) +test('Abgabe wird nachgespielt, gewertet und zählt die Streak', async () => { + const rsp = await submit({day: today, moves: botMoves(today, 1)}) + assert.equal(rsp.status, 200) + const body = (await rsp.json()) as SubmitRsp + assert.equal(body.counted, true) + assert.equal(body.result.rank, 1) + assert.equal(body.stats.streak, 1) + assert.equal(body.stats.played, 1) + assert.equal(body.top[0]?.name, 'alice') +}) + +test('nur der erste Versuch zählt', async () => { + await submit({day: today, moves: botMoves(today, 1)}) + const second = (await ( + await submit({day: today, moves: botMoves(today, 2)}) + ).json()) as SubmitRsp + assert.equal(second.counted, false) + assert.equal(second.stats.played, 1) + assert.equal(second.players, 1) +}) + +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', + JSON.stringify({ + streak, + bestStreak: streak, + played: streak, + totalStars: 0, + lastDay, + }), + ) + stored(addDays(today, -1), 4) + let body = (await ( + await submit({day: today, moves: botMoves(today, 1)}) + ).json()) as SubmitRsp + assert.equal(body.stats.streak, 5) + + hashes.clear() + zsets.clear() + stored(addDays(today, -2), 4) + body = (await ( + await submit({day: today, moves: botMoves(today, 1)}) + ).json()) as SubmitRsp + assert.equal(body.stats.streak, 1) + assert.equal(body.stats.bestStreak, 4) +}) + +test('alter Post: wird gewertet, hält die Streak aber nicht am Leben', async () => { + const yesterday = addDays(today, -1) + ctx.postData = {day: yesterday} + const body = (await ( + await submit({day: yesterday, moves: botMoves(yesterday, 1)}) + ).json()) as SubmitRsp + assert.equal(body.counted, true) + assert.equal(body.stats.streak, 0) +}) + +test('Bestenliste: bei Gleichstand gewinnt die frühere Abgabe', async () => { + const moves = botMoves(today, 1) + for (const name of ['bob', 'carol']) { + ctx = {userId: `t2_${name}`, username: name, postData: {day: today}} + await submit({day: today, moves}) + } + const day = await getDay() + assert.deepEqual( + day.top.map(r => r.name), + ['bob', 'carol'], + ) +}) + +test('ungültige Abgaben werden abgelehnt', async () => { + const moves = botMoves(today, 1) + const cases: [unknown, number][] = [ + [{day: today, moves: moves.slice(0, 2)}, 400], // Partie nicht fertig + [{day: today, moves: [['M', 999]]}, 400], // Feld gibt es nicht + [{day: addDays(today, -1), moves}, 400], // falscher Tag + ] + for (const [body, status] of cases) { + const rsp = await submit(body) + assert.equal(rsp.status, status) + } + ctx.userId = undefined + assert.equal((await submit({day: today, moves})).status, 401) + assert.equal((await getDay()).players, 0) }) test('404', async () => { diff --git a/src/server/server.ts b/src/server/server.ts index 41a5e95..b6b2b65 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -10,18 +10,36 @@ import { Endpoint, EndpointMethod, type ErrorRsp, - type GetCounterRsp, - type IncCounterReq, - type IncCounterRsp, + type GetDayRsp, + type Goals, + type SubmitReq, + type SubmitRsp, } from '../shared/api.ts' -import {dbGetCounter, dbIncCounter} from './db.ts' +import {type DailyMap, dailyMap, dayLabel, isDay} from '../shared/daily.ts' +import { + dbGetGoals, + dbGetPostId, + dbGetResult, + dbGetStats, + dbPlayers, + dbRecordPlay, + dbSaveFirstResult, + dbSetGoals, + dbSetPostId, + dbTop, +} from './db.ts' +import {goalsOf, verifyGame} from './verify.ts' -type AnyRsp = - | GetCounterRsp - | IncCounterRsp - | UiResponse - | TriggerResponse - | ErrorRsp +type AnyRsp = GetDayRsp | SubmitRsp | UiResponse | TriggerResponse | ErrorRsp + +/** Fehler mit HTTP-Status, den route() als ErrorRsp zurückgibt */ +class HttpError extends Error { + readonly status: number + constructor(status: number, message: string) { + super(message) + this.status = status + } +} export async function onReq( reqMsg: IncomingMessage, @@ -30,9 +48,17 @@ export async function onReq( try { await route(reqMsg, rspMsg) } catch (err) { + if (err instanceof HttpError) { + writeJson( + err.status, + {error: err.message, status: err.status}, + rspMsg, + ) + return + } const msg = `server error; ${err instanceof Error ? err.stack : err}` console.error(msg) - writeJson(500, {error: msg, status: 500}, rspMsg) + writeJson(500, {error: 'server error', status: 500}, rspMsg) } } @@ -40,7 +66,7 @@ async function route( reqMsg: IncomingMessage, rspMsg: ServerResponse, ): Promise { - const endpoint = reqMsg.url?.slice(1) as Endpoint + const endpoint = reqMsg.url?.slice(1).split('?')[0] as Endpoint const method = EndpointMethod[endpoint] let rsp: AnyRsp @@ -48,17 +74,19 @@ async function route( rsp = {error: 'not found', status: 404} } else { switch (endpoint) { - case Endpoint.GetCounter: - rsp = await routeGetCounter() + case Endpoint.GetDay: + rsp = await routeGetDay() break - case Endpoint.IncCounter: - rsp = await routeInc(reqMsg) + case Endpoint.Submit: + rsp = await routeSubmit(reqMsg) break case Endpoint.OnMenuNewPost: rsp = await routeMenuNewPost() break case Endpoint.OnAppInstall: - rsp = await routeAppInstall() + case Endpoint.OnDailyPost: + await ensureDailyPost() + rsp = {} break default: endpoint satisfies never @@ -70,37 +98,119 @@ async function route( writeJson('status' in rsp ? rsp.status : 200, rsp, rspMsg) } -async function routeGetCounter(): Promise { - const t3 = context.postId - if (!t3) throw Error('no t3') - return {count: await dbGetCounter(t3)} +/** Tag dieses Posts: aus den Post-Daten, sonst heute (UTC) */ +function postMap(): DailyMap { + const day = context.postData?.day + return dailyMap(isDay(day) ? new Date(`${day}T00:00:00Z`) : new Date()) } -async function routeInc(reqMsg: IncomingMessage): Promise { - const t3 = context.postId - if (!t3) throw Error('no t3') - const req = await readJson(reqMsg) - return {count: await dbIncCounter(t3, req.amount)} +async function goalsFor(map: DailyMap): Promise { + const cached = await dbGetGoals(map.day) + if (cached) return cached + const goals = goalsOf(map) + await dbSetGoals(map.day, goals) + return goals } -async function routeMenuNewPost(): Promise { - const post = await reddit.submitCustomPost({title: context.appSlug}) +async function routeGetDay(): Promise { + const map = postMap() + const today = dailyMap(new Date()).day + const userId = context.userId + const [goals, top, players, mine, stats] = await Promise.all([ + goalsFor(map), + dbTop(map.day), + dbPlayers(map.day), + userId ? dbGetResult(map.day, userId) : null, + userId ? dbGetStats(userId, today) : null, + ]) return { - showToast: {text: `Post ${post.id} created.`, appearance: 'success'}, - navigateTo: post.url, + day: map.day, + number: map.number, + mapId: map.mapId, + trait: map.trait, + goals, + today: map.day === today, + loggedIn: Boolean(userId), + mine, + stats, + top, + players, } } -async function routeAppInstall(): Promise { - await reddit.submitCustomPost({title: context.appSlug}) - return {} +async function routeSubmit(reqMsg: IncomingMessage): Promise { + const userId = context.userId + if (!userId) throw new HttpError(401, 'log in to enter the leaderboard') + const req = await readJson>(reqMsg) + const map = postMap() + // Der Client schickt den Tag mit; passt er nicht zum Post, wurde etwas verwechselt + if (req.day !== map.day) throw new HttpError(400, 'wrong day') + + const goals = await goalsFor(map) + const result = verifyGame(map, goals, req.moves) + if (!result.ok) throw new HttpError(400, result.error) + + const today = dailyMap(new Date()).day + const counted = await dbSaveFirstResult(map.day, userId, { + name: context.username ?? 'anonymous', + stars: result.stars, + gold: result.gold, + lands: result.lands, + }) + const stats = counted + ? await dbRecordPlay(userId, map.day, today, result.stars) + : await dbGetStats(userId, today) + const [mine, top, players] = await Promise.all([ + dbGetResult(map.day, userId), + dbTop(map.day), + dbPlayers(map.day), + ]) + if (!mine) throw new HttpError(500, 'result missing') + return { + counted, + result: mine, + played: {stars: result.stars, gold: result.gold, lands: result.lands}, + stats, + top, + players, + } +} + +/** Post für heute anlegen, falls es ihn noch nicht gibt (Scheduler, Installation, Menü) */ +async function ensureDailyPost(): Promise<{id: string; created: boolean}> { + const map = dailyMap(new Date()) + const existing = await dbGetPostId(map.day) + if (existing) return {id: existing, created: false} + const post = await reddit.submitCustomPost({ + title: `Ninelands #${map.number} · ${dayLabel(map.day)}`, + entry: 'default', + postData: {day: map.day}, + }) + await dbSetPostId(map.day, post.id) + return {id: post.id, created: true} +} + +async function routeMenuNewPost(): Promise { + const {id, created} = await ensureDailyPost() + const post = await reddit.getPostById(id as `t3_${string}`) + return { + showToast: { + text: created ? "Today's post created." : "Today's post already exists.", + appearance: 'success', + }, + navigateTo: post.url, + } } async function readJson(reqMsg: IncomingMessage): Promise { const chunks: Uint8Array[] = [] reqMsg.on('data', chunk => chunks.push(chunk)) await once(reqMsg, 'end') - return JSON.parse(`${Buffer.concat(chunks)}`) + try { + return JSON.parse(`${Buffer.concat(chunks)}`) + } catch { + throw new HttpError(400, 'invalid json') + } } function writeJson( diff --git a/src/server/verify.test.ts b/src/server/verify.test.ts new file mode 100644 index 0000000..8552df4 --- /dev/null +++ b/src/server/verify.test.ts @@ -0,0 +1,11 @@ +import assert from 'node:assert/strict' +import {test} from 'node:test' +import {rankScore} from './verify.ts' + +test('Rangwert: Sterne vor Gold, Gold vor Abgabezeit', () => { + assert.ok(rankScore(3, 10, 99) > rankScore(2, 900, 1)) + assert.ok(rankScore(2, 101, 99) > rankScore(2, 100, 1)) + assert.ok(rankScore(2, 100, 1) > rankScore(2, 100, 2)) + // auch mit negativem Gold (Baukosten) bleibt die Reihenfolge + assert.ok(rankScore(0, -5, 1) < rankScore(0, 0, 50)) +}) diff --git a/src/server/verify.ts b/src/server/verify.ts new file mode 100644 index 0000000..dad1bab --- /dev/null +++ b/src/server/verify.ts @@ -0,0 +1,44 @@ +// Prüfung einer Abgabe: Züge auf der Tageskarte mit derselben Engine nachspielen und Sterne selbst berechnen. +// Bewusst nicht verifyProgress aus engine/core/progress.ts – das kennt nur die Website-Tageskarte und zieht die +// Kampagnen-Daten ins Bundle. +import {Game} from '../engine/core/Game.ts' +import {computeGoals, starsOf} from '../engine/core/goals.ts' +import {applyMoves} from '../engine/core/replay.ts' +import type {Goals} from '../shared/api.ts' +import {type DailyMap, dailyBoard} from '../shared/daily.ts' + +/** Ziele der Tageskarte (Bot-Partien, ≈ 50 ms) */ +export function goalsOf(map: DailyMap): Goals { + return computeGoals(dailyBoard(map)) +} + +export type Verified = + | {ok: true; gold: number; lands: number; stars: number} + | {ok: false; error: 'INVALID_MOVES' | 'NOT_FINISHED'} + +export function verifyGame( + map: DailyMap, + goals: Goals, + moves: unknown, +): Verified { + // Gleiche Optionen wie im Client; undoLimit ist egal, nachgespielt wird nur die endgültige Zugfolge + const game = new Game(dailyBoard(map), {undoLimit: 0}) + if (!applyMoves(game, moves)) return {ok: false, error: 'INVALID_MOVES'} + // Gewertet wird nur eine fertige Partie (kein Zug mehr möglich) – sonst könnte man früh aufhören, um Zeit zu sparen + if (!game.isGameOver()) return {ok: false, error: 'NOT_FINISHED'} + return { + ok: true, + gold: game.score, + lands: game.regionCounts()[0] ?? 0, + stars: starsOf(game, goals).count, + } +} + +/** + * Wert in der Bestenliste (Sorted Set, absteigend): Sterne vor Gold, bei Gleichstand die frühere Abgabe. + * seq = laufende Nummer der Abgabe (1, 2, …); der Bruchteil 1/(seq+1) liegt in (0, ½] und ist für frühere Abgaben größer. + * Gold bleibt weit unter 100 000, also stören sich Sterne, Gold und Bruchteil nicht. + */ +export function rankScore(stars: number, gold: number, seq: number): number { + return stars * 100_000 + gold + 1 / (seq + 1) +} diff --git a/src/shared/api.ts b/src/shared/api.ts index 22c5655..fbbbb2b 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -1,24 +1,89 @@ -/** Generic error detail for all responses. */ +/** Fehler-Antwort aller Endpunkte */ export type ErrorRsp = {error: string; status: number} -/** The current counter state for this post. */ -export type GetCounterRsp = {count: number} +/** Ein Zug, wie ihn die Engine kodiert: [Buchstabe der Figur, Feld] */ +export type EncodedMove = [string, number] -/** Increment the post counter by a signed amount. */ -export type IncCounterReq = {amount: number} -export type IncCounterRsp = {count: number} +/** Sterne einer Partie (wie im Hauptspiel: Gold, Länder, beides) */ +export type Stars = { + gold: boolean + lands: boolean + both: boolean + count: number +} + +/** Ziele der Tageskarte */ +export type Goals = {gold: number; lands: number} + +/** Gewertetes Ergebnis eines Spielers für einen Tag */ +export type DayResult = { + stars: number + gold: number + lands: number + /** Platz in der Bestenliste (1 = beste) */ + rank: number +} + +/** Eintrag der Bestenliste */ +export type LeaderRow = { + rank: number + name: string + stars: number + gold: number + lands: number +} + +/** Persönliche Werte über alle Tage */ +export type PlayerStats = { + streak: number + bestStreak: number + played: number + totalStars: number +} + +/** Alles, was der Post zum Anzeigen braucht */ +export type GetDayRsp = { + day: string + number: number + mapId: string + trait: string + goals: Goals + /** Ist dieser Post die Karte von heute (UTC)? Nur dann zählt eine Abgabe für die Streak. */ + today: boolean + /** Angemeldet? Ohne Konto kann man spielen, aber nicht in die Bestenliste. */ + loggedIn: boolean + /** Eigenes gewertetes Ergebnis, falls schon gespielt */ + mine: DayResult | null + stats: PlayerStats | null + top: LeaderRow[] + players: number +} + +export type SubmitReq = {day: string; moves: EncodedMove[]} +export type SubmitRsp = { + /** false = es gab schon einen gewerteten ersten Versuch; result ist dann der alte */ + counted: boolean + result: DayResult + /** Ergebnis dieser Partie (auch wenn nicht gewertet) */ + played: {stars: number; gold: number; lands: number} + stats: PlayerStats + top: LeaderRow[] + players: number +} export type Endpoint = (typeof Endpoint)[keyof typeof Endpoint] export const Endpoint = { - GetCounter: 'api/counter', - IncCounter: 'api/counter/inc', + GetDay: 'api/day', + Submit: 'api/submit', OnAppInstall: 'internal/on/app/install', OnMenuNewPost: 'internal/on/menu/new-post', + OnDailyPost: 'internal/scheduler/daily-post', } as const export const EndpointMethod = { - [Endpoint.GetCounter]: 'GET', - [Endpoint.IncCounter]: 'POST', + [Endpoint.GetDay]: 'GET', + [Endpoint.Submit]: 'POST', [Endpoint.OnAppInstall]: 'POST', [Endpoint.OnMenuNewPost]: 'POST', + [Endpoint.OnDailyPost]: 'POST', } as const satisfies {[endpoint: string]: 'GET' | 'POST'} diff --git a/src/shared/daily.ts b/src/shared/daily.ts index 94b5a58..238c5a0 100644 --- a/src/shared/daily.ts +++ b/src/shared/daily.ts @@ -39,3 +39,27 @@ export function dailyMap(date: Date): DailyMap { export function dailyBoard(map: DailyMap): Board { return generateBoard(map.seed, {trait: map.trait}) } + +/** Gültiger UTC-Tag „JJJJ-MM-TT“ (aus Post-Daten oder Anfragen) */ +export function isDay(value: unknown): value is string { + return ( + typeof value === 'string' && + /^\d{4}-\d{2}-\d{2}$/.test(value) && + dailyKey(new Date(`${value}T00:00:00Z`)) === value + ) +} + +/** Tag „JJJJ-MM-TT“ plus n Tage (UTC) */ +export function addDays(day: string, n: number): string { + return dailyKey(new Date(Date.parse(`${day}T00:00:00Z`) + n * 86_400_000)) +} + +/** Lesbares Datum für Titel und Startbildschirm, z. B. „Fri, Nov 13“ */ +export function dayLabel(day: string): string { + return new Date(`${day}T12:00:00Z`).toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + timeZone: 'UTC', + }) +}