ninelands-reddit/src/server/server.test.ts
Lukas Müllner 90150026ab feat: Nummer „#N“ zählt je Subreddit ab dem ersten Tag dort
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 18:30:59 +01:00

379 lines
11 KiB
TypeScript

import assert from 'node:assert/strict'
import {createServer} from 'node:http'
import type {AddressInfo, Server} from 'node:net'
import {after, before, beforeEach, test} from 'node:test'
import {type Context, reddit, 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 {
type CommentRsp,
Endpoint,
type ErrorRsp,
type GetDayRsp,
type SetLookRsp,
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}
/** Ablaufzeiten, die db.ts gesetzt hat (Schlüssel → Sekunden) */
const expiries = new Map<string, number>()
const fake: Partial<typeof redis> = {
expire: async (k, seconds) => {
expiries.set(k, seconds)
},
get: async k => strings.get(k),
set: async (k, v, opts) => {
if (opts?.nx && strings.has(k)) return ''
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)
},
}
// Reddit-API-Attrappe: Kommentare und Flair nur mitschreiben
const comments: {id: string; text: string; runAs?: string}[] = []
const flairs: {username: string; text?: string}[] = []
const originalReddit = {
submitComment: reddit.submitComment,
setUserFlair: reddit.setUserFlair,
}
let server: Server
let serverURL: string
let ctx: {userId?: string; username?: string; postData?: {day: string}}
before(async () => {
Object.assign(redis, fake)
reddit.submitComment = (async (o: {
id: string
text: string
runAs?: string
}) => {
comments.push(o)
return {}
}) as unknown as typeof reddit.submitComment
reddit.setUserFlair = (async (o: {username: string; text?: string}) => {
flairs.push(o)
}) as unknown as typeof reddit.setUserFlair
server = createServer(async (req, rsp) => {
await runWithContext(
{
appName: 'ninelands-game',
postId: 't3_123',
...ctx,
} as unknown as Context,
() => onReq(req, rsp),
)
})
await new Promise<void>(resolve => {
server.listen(0, '127.0.0.1', () => resolve())
})
serverURL = `http://127.0.0.1:${(server.address() as AddressInfo).port}`
})
after(async () => {
Object.assign(redis, original)
Object.assign(reddit, originalReddit)
if (!server.listening) return
await new Promise<void>((resolve, reject) => {
server.close(err => (err ? reject(err) : resolve()))
})
})
const today = dailyMap(new Date()).day
beforeEach(() => {
strings.clear()
expiries.clear()
hashes.clear()
zsets.clear()
comments.length = 0
flairs.length = 0
ctx = {userId: 't2_alice', username: 'alice', postData: {day: today}}
})
/** 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)
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('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('Nutzerdaten laufen nach 30 Tagen ab (Devvit-Regel zu Löschungen)', async () => {
await submit({day: today, moves: botMoves(today, 1)})
const month = 30 * 24 * 60 * 60
for (const k of [
`results:${today}`,
`board:${today}`,
`seq:${today}`,
'player:t2_alice',
])
assert.equal(expiries.get(k), month, k)
})
test('nur der erste Versuch zählt', async () => {
await submit({day: today, moves: botMoves(today, 1)})
const second = (await (
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) =>
strings.set(
'player: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: []}, 400], // leere Partie
[{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('früh beendete Partie wird gewertet', async () => {
const rsp = await submit({day: today, moves: botMoves(today, 1).slice(0, 3)})
assert.equal(rsp.status, 200)
assert.equal(((await rsp.json()) as SubmitRsp).counted, true)
})
async function post(endpoint: string, body: unknown): Promise<Response> {
return fetch(`${serverURL}/${endpoint}`, {
body: JSON.stringify(body),
headers: {'Content-Type': 'application/json'},
method: 'POST',
})
}
test('Aussehen: Gesperrtes wird verweigert, Freigeschaltetes gespeichert', async () => {
const wanted = {
color: 'cyan',
faction: 'orc',
theme: 'autumn',
banner: 'pennant',
}
let look = (
(await (await post(Endpoint.SetLook, {look: wanted})).json()) as SetLookRsp
).look
assert.deepEqual(look, {
color: 'cyan',
faction: 'kingdom',
theme: 'classic',
banner: 'classic',
})
strings.set(
'player:t2_alice',
JSON.stringify({
streak: 3,
bestStreak: 3,
played: 9,
totalStars: 25,
lastDay: today,
}),
)
look = (
(await (await post(Endpoint.SetLook, {look: wanted})).json()) as SetLookRsp
).look
assert.deepEqual(look, wanted)
assert.deepEqual((await getDay()).look, wanted)
})
test('Ergebnis als Kommentar: Text vom Server, im Namen des Spielers, einmal je Tag', async () => {
assert.equal((await post(Endpoint.Comment, {})).status, 400) // erst spielen
await submit({day: today, moves: botMoves(today, 1)})
const first = (await (await post(Endpoint.Comment, {})).json()) as CommentRsp
const second = (await (await post(Endpoint.Comment, {})).json()) as CommentRsp
assert.equal(first.posted, true)
assert.equal(second.posted, false)
assert.equal(comments.length, 1)
assert.equal(comments[0]?.runAs, 'USER')
assert.match(
comments[0]?.text ?? '',
/^Ninelands #\d+ [★☆]{3} · \d\/9 lands · -?\d+ gold · #1 today$/,
)
assert.equal((await getDay()).commented, true)
})
test('Flair: Streak ab 2 Tagen', async () => {
strings.set(
'player:t2_alice',
JSON.stringify({
streak: 4,
bestStreak: 4,
played: 4,
totalStars: 0,
lastDay: addDays(today, -1),
}),
)
await submit({day: today, moves: botMoves(today, 1)})
assert.deepEqual(flairs.at(-1)?.text, '🔥 5-day streak')
})
test('Nummer zählt ab dem ersten Tag im Subreddit', async () => {
assert.equal((await getDay()).number, 1)
strings.set('firstDay', addDays(today, -9))
assert.equal((await getDay()).number, 10)
})
test('404', async () => {
const rsp = await fetch(serverURL)
assert.equal(rsp.status, 404)
assert.deepEqual<ErrorRsp>(await rsp.json(), {
error: 'not found',
status: 404,
})
})