feat(server): Tageskarte, geprüfte Abgabe, Bestenliste, Streak, Tagespost

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lukas Müllner 2026-11-13 16:30:26 +01:00
parent b82fd6257e
commit 0acee948e0
11 changed files with 693 additions and 173 deletions

20
PLAN.md
View file

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

View file

@ -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"
},

View file

@ -1,58 +0,0 @@
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
}

View file

@ -1,23 +1,2 @@
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()
// Platzhalter – das Spiel folgt im nächsten Schritt von Phase 1
export {}

View file

@ -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<number> {
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<Goals | undefined> {
const raw = await redis.get(`goals:${day}`)
return raw ? (JSON.parse(raw) as Goals) : undefined
}
export async function dbIncCounter(t3: T3, amount: number): Promise<number> {
return redis.incrBy(counterKey(t3), amount)
export async function dbSetGoals(day: string, goals: Goals): Promise<void> {
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<StoredResult, 'at'>,
): Promise<boolean> {
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<DayResult | null> {
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<number> {
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<number> {
return redis.zCard(`board:${day}`)
}
export async function dbTop(day: string): Promise<LeaderRow[]> {
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<StoredPlayer> {
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<PlayerStats> {
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<PlayerStats> {
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<string | undefined> {
return redis.hGet('posts', day)
}
export async function dbSetPostId(day: string, postId: string): Promise<void> {
await redis.hSet('posts', {[day]: postId})
}

View file

@ -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<string, string>()
const hashes = new Map<string, Map<string, string>>()
const zsets = new Map<string, Map<string, number>>()
function hash(k: string): Map<string, string> {
let h = hashes.get(k)
if (!h) {
h = new Map<string, string>()
hashes.set(k, h)
}
return h
}
function zset(k: string): Map<string, number> {
let z = zsets.get(k)
if (!z) {
z = new Map<string, number>()
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<typeof redis> = {
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<string, number>()
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<void>(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<void>((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<GetCounterRsp>(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<Response> {
return fetch(`${serverURL}/${Endpoint.Submit}`, {
body: JSON.stringify(body),
headers: {'Content-Type': 'application/json'},
method: 'POST',
})
}
async function getDay(): Promise<GetDayRsp> {
const rsp = await fetch(`${serverURL}/${Endpoint.GetDay}`)
assert.equal(rsp.status, 200)
assert.equal(rsp.headers.get('Content-Type'), 'application/json')
assert.deepEqual<IncCounterRsp>(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<ErrorRsp>(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 () => {

View file

@ -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<ErrorRsp>(
err.status,
{error: err.message, status: err.status},
rspMsg,
)
return
}
const msg = `server error; ${err instanceof Error ? err.stack : err}`
console.error(msg)
writeJson<ErrorRsp>(500, {error: msg, status: 500}, rspMsg)
writeJson<ErrorRsp>(500, {error: 'server error', status: 500}, rspMsg)
}
}
@ -40,7 +66,7 @@ async function route(
reqMsg: IncomingMessage,
rspMsg: ServerResponse,
): Promise<void> {
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<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)}
/** 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<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 goalsFor(map: DailyMap): Promise<Goals> {
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<UiResponse> {
const post = await reddit.submitCustomPost({title: context.appSlug})
async function routeGetDay(): Promise<GetDayRsp> {
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<TriggerResponse> {
await reddit.submitCustomPost({title: context.appSlug})
return {}
async function routeSubmit(reqMsg: IncomingMessage): Promise<SubmitRsp> {
const userId = context.userId
if (!userId) throw new HttpError(401, 'log in to enter the leaderboard')
const req = await readJson<Partial<SubmitReq>>(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<UiResponse> {
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<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)}`)
try {
return JSON.parse(`${Buffer.concat(chunks)}`)
} catch {
throw new HttpError(400, 'invalid json')
}
}
function writeJson<T extends PartialJsonValue>(

11
src/server/verify.test.ts Normal file
View file

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

44
src/server/verify.ts Normal file
View file

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

View file

@ -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'}

View file

@ -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',
})
}