ninelands-reddit/src/server/server.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

352 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {once} from 'node:events'
import type {IncomingMessage, ServerResponse} from 'node:http'
import {context, reddit} from '@devvit/web/server'
import type {
PartialJsonValue,
TriggerResponse,
UiResponse,
} from '@devvit/web/shared'
import {
type CommentRsp,
Endpoint,
EndpointMethod,
type ErrorRsp,
type GetDayRsp,
type Goals,
type SetLookReq,
type SetLookRsp,
type SubmitReq,
type SubmitRsp,
} from '../shared/api.ts'
import {
addDays,
type DailyMap,
dailyMap,
dayLabel,
dayNumber,
isDay,
} from '../shared/daily.ts'
import {flairText, shareLine} from '../shared/format.ts'
import {DEFAULT_LOOK} from '../shared/look.ts'
import {
dbClaimComment,
dbFirstDay,
dbGetGoals,
dbGetLook,
dbGetPlayer,
dbGetPostId,
dbGetResult,
dbGetStats,
dbHasCommented,
dbPlayers,
dbRecordPlay,
dbSaveFirstResult,
dbSetCrown,
dbSetGoals,
dbSetLook,
dbSetPostId,
dbTop,
dbWinner,
hasCrown,
} from './db.ts'
import {routeJourney} from './journeys.ts'
import {goalsOf, verifyGame} from './verify.ts'
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,
rspMsg: ServerResponse,
): Promise<void> {
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: 'server error', status: 500}, rspMsg)
}
}
async function route(
reqMsg: IncomingMessage,
rspMsg: ServerResponse,
): Promise<void> {
const endpoint = reqMsg.url?.slice(1).split('?')[0] as Endpoint
const method = EndpointMethod[endpoint]
let rsp: AnyRsp | PartialJsonValue
if (method !== reqMsg.method) {
rsp = {error: 'not found', status: 404}
} else {
switch (endpoint) {
case Endpoint.GetDay:
rsp = await routeGetDay()
break
case Endpoint.Submit:
rsp = await routeSubmit(reqMsg)
break
case Endpoint.SetLook:
rsp = await routeSetLook(reqMsg)
break
case Endpoint.Comment:
rsp = await routeComment()
break
case Endpoint.OnMenuNewPost:
rsp = await routeMenuNewPost()
break
case Endpoint.OnAppInstall:
await ensureDailyPost()
rsp = {}
break
case Endpoint.OnDailyPost:
await crownYesterday()
await ensureDailyPost()
rsp = {}
break
case Endpoint.JourneyStart:
case Endpoint.JourneyProgress:
case Endpoint.JourneyInteraction:
case Endpoint.JourneyEnd:
case Endpoint.JourneyAppReady:
rsp = await routeJourney(
endpoint,
await readJson<Record<string, unknown>>(reqMsg).catch(() => ({})),
)
break
default:
endpoint satisfies never
rsp = {error: 'not found', status: 404}
break
}
}
const status =
rsp &&
typeof rsp === 'object' &&
'status' in rsp &&
typeof rsp.status === 'number'
? rsp.status
: 200
writeJson<PartialJsonValue>(status, rsp as PartialJsonValue, rspMsg)
}
/** 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 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 routeGetDay(): Promise<GetDayRsp> {
const map = postMap()
const today = dailyMap(new Date()).day
const userId = context.userId
const [goals, top, players, mine, stats, look, commented] = await Promise.all(
[
goalsFor(map),
dbTop(map.day),
dbPlayers(map.day),
userId ? dbGetResult(map.day, userId) : null,
userId ? dbGetStats(userId, today) : null,
userId ? dbGetLook(userId, today) : DEFAULT_LOOK,
userId ? dbHasCommented(map.day, userId) : false,
],
)
return {
day: map.day,
number: dayNumber(await dbFirstDay(today), map.day),
mapId: map.mapId,
trait: map.trait,
goals,
today: map.day === today,
loggedIn: Boolean(userId),
mine,
stats,
top,
players,
look,
commented,
}
}
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')
if (counted) await updateFlair(userId, today)
return {
counted,
result: mine,
played: {stars: result.stars, gold: result.gold, lands: result.lands},
stats,
top,
players,
}
}
async function routeSetLook(reqMsg: IncomingMessage): Promise<SetLookRsp> {
const userId = context.userId
if (!userId) throw new HttpError(401, 'log in to customise your realm')
const req = await readJson<Partial<SetLookReq>>(reqMsg)
const today = dailyMap(new Date()).day
return {look: await dbSetLook(userId, req.look, today)}
}
/**
* Ergebnis als Kommentar unter den Post – im Namen des Spielers (runAs USER, braucht asUser SUBMIT_COMMENT).
* Den Text baut der Server aus dem gespeicherten Ergebnis, nicht aus Eingaben des Clients. Einmal je Tag.
*/
async function routeComment(): Promise<CommentRsp> {
const userId = context.userId
const postId = context.postId
if (!userId || !postId) throw new HttpError(401, 'log in to comment')
const map = postMap()
const mine = await dbGetResult(map.day, userId)
if (!mine) throw new HttpError(400, 'play first')
if (!(await dbClaimComment(map.day, userId))) return {posted: false}
await reddit.submitComment({
id: postId,
text: `${shareLine(dayNumber(await dbFirstDay(map.day), map.day), mine)} · #${mine.rank} today`,
runAs: 'USER',
})
return {posted: true}
}
/** Flair im Subreddit: 👑 am Tag nach einem Tagessieg, 🔥 ab 2 Tagen Streak. Fehler nur protokollieren. */
async function updateFlair(userId: string, today: string): Promise<void> {
const username = context.username
if (!username) return
try {
const p = await dbGetPlayer(userId)
const stats = await dbGetStats(userId, today)
const text = flairText(hasCrown(p, today), stats.streak)
if (!text) return
await reddit.setUserFlair({
subredditName: context.subredditName,
username,
text,
})
} catch (err) {
console.error(`flair; ${err instanceof Error ? err.message : err}`)
}
}
/** Beim Tageswechsel: Sieger von gestern bekommt die Krone (Flair sofort aktualisieren) */
async function crownYesterday(): Promise<void> {
const today = dailyMap(new Date()).day
const yesterday = addDays(today, -1)
const winner = await dbWinner(yesterday)
if (!winner) return
const p = await dbSetCrown(winner, yesterday)
try {
const user = await reddit.getUserById(winner as `t2_${string}`)
if (!user) return
const stats = await dbGetStats(winner, today)
await reddit.setUserFlair({
subredditName: context.subredditName,
username: user.username,
text: flairText(hasCrown(p, today), stats.streak),
})
} catch (err) {
console.error(`crown flair; ${err instanceof Error ? err.message : err}`)
}
}
/** 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 #${dayNumber(await dbFirstDay(map.day), map.day)} · ${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')
try {
return JSON.parse(`${Buffer.concat(chunks)}`)
} catch {
throw new HttpError(400, 'invalid json')
}
}
function writeJson<T extends PartialJsonValue>(
status: number,
json: Readonly<T>,
rsp: ServerResponse,
): void {
const body = JSON.stringify(json)
const len = Buffer.byteLength(body)
rsp.writeHead(status, {
'Content-Length': len,
'Content-Type': 'application/json',
})
rsp.end(body)
}