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, isDay, } from '../shared/daily.ts' import {flairText, shareLine} from '../shared/format.ts' import {DEFAULT_LOOK} from '../shared/look.ts' import { dbClaimComment, 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 { try { await route(reqMsg, rspMsg) } catch (err) { if (err instanceof HttpError) { writeJson( err.status, {error: err.message, status: err.status}, rspMsg, ) return } const msg = `server error; ${err instanceof Error ? err.stack : err}` console.error(msg) writeJson(500, {error: 'server error', status: 500}, rspMsg) } } async function route( reqMsg: IncomingMessage, rspMsg: ServerResponse, ): Promise { 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>(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(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 { 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 { 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: map.number, 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 { const userId = context.userId if (!userId) throw new HttpError(401, 'log in to enter the leaderboard') const req = await readJson>(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 { const userId = context.userId if (!userId) throw new HttpError(401, 'log in to customise your realm') const req = await readJson>(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 { 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(map.number, 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 { 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 { 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 #${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 { 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(reqMsg: IncomingMessage): Promise { 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( status: number, json: Readonly, 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) }