372 lines
11 KiB
TypeScript
372 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) => {
|
|
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('404', async () => {
|
|
const rsp = await fetch(serverURL)
|
|
assert.equal(rsp.status, 404)
|
|
assert.deepEqual<ErrorRsp>(await rsp.json(), {
|
|
error: 'not found',
|
|
status: 404,
|
|
})
|
|
})
|