Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
60debf0a7a
11 changed files with 693 additions and 173 deletions
20
PLAN.md
20
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.
|
||||
|
|
|
|||
20
devvit.json
20
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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
|
|||
167
src/server/db.ts
167
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<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})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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
11
src/server/verify.test.ts
Normal 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
44
src/server/verify.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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'}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue