352 lines
9.9 KiB
TypeScript
352 lines
9.9 KiB
TypeScript
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)
|
||
}
|