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() 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} /** Ablaufzeiten, die db.ts gesetzt hat (Schlüssel → Sekunden) */ const expiries = new Map() const fake: Partial = { expire: async (k, seconds) => { expiries.set(k, seconds) }, 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) }, } // 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(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((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 { 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) 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 { 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('404', async () => { const rsp = await fetch(serverURL) assert.equal(rsp.status, 404) assert.deepEqual(await rsp.json(), { error: 'not found', status: 404, }) })