From 51d47cd0d0a81417fa5a6a0ef98f9a859c1d571f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20M=C3=BCllner?= Date: Fri, 13 Nov 2026 17:33:53 +0100 Subject: [PATCH] feat: Realm-Auswahl (Farbe, Volk, Thema, Banner), Freischaltungen, Flair, Ergebnis als Kommentar Co-Authored-By: Claude Opus 5.5 --- PLAN.md | 21 +++++ devvit.json | 6 +- src/client/game.css | 8 ++ src/client/game.ts | 49 +++++++++++- src/client/realm.ts | 164 ++++++++++++++++++++++++++++++++++++++ src/client/result.ts | 10 +-- src/client/splash.css | 14 ++++ src/client/splash.ts | 22 +++++ src/client/style.css | 55 +++++++++++++ src/client/texts.ts | 4 +- src/client/unlocks.ts | 34 ++++++++ src/server/db.ts | 84 ++++++++++++++++++- src/server/server.test.ts | 99 ++++++++++++++++++++++- src/server/server.ts | 120 ++++++++++++++++++++++++++-- src/shared/api.ts | 14 ++++ src/shared/format.test.ts | 18 +++++ src/shared/format.ts | 22 +++++ src/shared/look.test.ts | 53 ++++++++++++ src/shared/look.ts | 122 ++++++++++++++++++++++++++++ 19 files changed, 891 insertions(+), 28 deletions(-) create mode 100644 src/client/realm.ts create mode 100644 src/client/unlocks.ts create mode 100644 src/shared/format.test.ts create mode 100644 src/shared/format.ts create mode 100644 src/shared/look.test.ts create mode 100644 src/shared/look.ts diff --git a/PLAN.md b/PLAN.md index 94e9823..b8600d7 100644 --- a/PLAN.md +++ b/PLAN.md @@ -239,3 +239,24 @@ Abgabe: **9. Dezember 2026, 03:00 MEZ**. Eingereicht wird in „Best Daily Game `server.test.ts`. - **Playtest-Fallstrick:** Mehrere Änderungen in kurzer Folge (vor allem an `devvit.json`) lösten zwei Uploads mit derselben Versionsnummer aus („This AppVersion already exists“), `devvit playtest` brach ab → neu starten. + +### 2026-11-13 – Phase 2: Aussehen, Flair, Kommentar + +- **Freischaltungen** (`src/shared/look.ts`, Browser und Server gleich): Farbe frei; Thema über die **beste** Streak + (3 Herbst, 7 Winter, 14 Nacht); Banner je 10 Sterne (Kreuz 50, Stern 60, Krone 80); Volk über Sterne (Orks 20, + Piraten 40). **Winterfest der Reddit-Fassung 1. Dez. – 6. Jan.** (Hauptspiel: ab 15. Dez.) – damit Winterfest-Thema + und Schneeflocken-Banner während der Jury-Bewertung (9.–16. Dez.) für alle frei sind. +- Auswahl über **„🎨 Realm“** auf dem Startbildschirm (einziger zusätzlicher Knopf, Besitzer einverstanden). Gesperrtes + zeigt die Bedingung. Speichern per `POST api/look`; der Server prüft gegen die Freischaltungen (`sanitizeLook`) – + Gesperrtes fällt auf den Standard zurück. Angewendet über `setBoardTheme/Banners/Look` aus der Brett-Kopie. +- Ergebniskarte: **„🎁 Unlocked: …“** bei neuen Freischaltungen (Vergleich der Werte vor und nach der Partie). +- **Ergebnis als Kommentar** (`POST api/comment`): `reddit.submitComment({runAs: 'USER'})`, braucht + `permissions.reddit.asUser: ["SUBMIT_COMMENT"]` (Reddit prüft das in der Review). Text baut der Server aus dem + gespeicherten Ergebnis, einmal je Spieler und Tag (`comments:{day}`, hSetNX). +- **Flair**: nach jeder gewerteten Partie `🔥 N-day streak` (ab 2); beim Tageswechsel bekommt der Sieger von gestern + `👑 Ruler of the day` (`crownDay`, gilt am Folgetag). Fehler beim Flair werden nur protokolliert. Reddit-Vorgabe: + kein Komma im Flair. +- **Fallstrick:** Jede Änderung an `devvit.json` lässt `devvit playtest` die Konfiguration neu laden; zusammen mit dem + Build gab es zweimal doppelte Versionsnummern und Abbruch. Bei Änderungen an `devvit.json` Playtest stoppen und neu + starten. +- Folge der 30-Tage-Regel: Freischaltungen gehen nach 30 Tagen ohne Spiel verloren. diff --git a/devvit.json b/devvit.json index 763625a..a6e56ee 100644 --- a/devvit.json +++ b/devvit.json @@ -16,7 +16,11 @@ "server": {}, "permissions": { "redis": true, - "journeys": true + "journeys": true, + "reddit": { + "enable": true, + "asUser": ["SUBMIT_COMMENT"] + } }, "menu": { "items": [ diff --git a/src/client/game.css b/src/client/game.css index 300a388..e19836a 100644 --- a/src/client/game.css +++ b/src/client/game.css @@ -232,3 +232,11 @@ button.end.armed { padding-left: 1.2rem; font-size: 0.9rem; } + +.unlocked { + margin: 0; + padding: 0.3rem 0.5rem; + background: var(--px-gold); + color: var(--px-ink); + font-size: 0.85rem; +} diff --git a/src/client/game.ts b/src/client/game.ts index 46aae60..a024666 100644 --- a/src/client/game.ts +++ b/src/client/game.ts @@ -17,12 +17,18 @@ import {starsOf} from '../engine/core/goals.ts' import {encodeMoves} from '../engine/core/replay.ts' import {UNIT_TYPES, type UnitType} from '../engine/core/units.ts' import type {WorldTrait} from '../engine/core/WorldRules.ts' -import type {EncodedMove, GetDayRsp, SubmitRsp} from '../shared/api.ts' +import { + type EncodedMove, + Endpoint, + type GetDayRsp, + type SubmitRsp, +} from '../shared/api.ts' import {dailyBoard, dailyMap, dayLabel} from '../shared/daily.ts' import {fetchDay, submitGame} from './api.ts' import {initMusic} from './audio.ts' import {byId, h, nodes} from './dom.ts' import {appReady, journeyEnd, journeyStar, journeyStart} from './journeys.ts' +import {applyLook} from './realm.ts' import {leaderTable, shareLine} from './result.ts' import { HINTS, @@ -34,6 +40,7 @@ import { UNIT_NAME, ZONE_NAMES, } from './texts.ts' +import {newUnlocks} from './unlocks.ts' const app = byId('app') @@ -100,6 +107,7 @@ async function main(): Promise { function start(): void { const map = dailyMap(new Date(`${day.day}T00:00:00Z`)) + applyLook(day.look) game = new Game(dailyBoard(map), {undoLimit: 1}) tool = 'HIVEMOTHER' finished = false @@ -193,7 +201,7 @@ function fill(el: HTMLElement, ...children: (Node | string)[]): HTMLElement { function spriteImg(unit: UnitType): HTMLImageElement { const img = h('img.tool-sprite') - img.src = spriteUrl(pieceSpriteName(unit, 'red', 'kingdom')) + img.src = spriteUrl(pieceSpriteName(unit, day.look.color, day.look.faction)) img.alt = '' return img } @@ -417,6 +425,33 @@ function showResult( const close = h('button.primary', 'Done') close.addEventListener('click', ev => exitExpandedMode(ev)) + // Ergebnis als Kommentar unter den Post (einmal je Tag, Text baut der Server) + let comment: HTMLButtonElement | null = null + if (rsp && day.loggedIn && !day.commented) { + const btn = h('button', '💬 Post to comments') + btn.addEventListener('click', async () => { + btn.disabled = true + try { + const r = await fetch(Endpoint.Comment, { + method: 'POST', + headers: {Accept: 'application/json'}, + }) + btn.textContent = r.ok ? 'Posted ✓' : 'Could not post' + if (r.ok) day.commented = true + } catch { + btn.textContent = 'Could not post' + } + }) + comment = btn + } + + // Neu freigeschaltet (nur bei gewerteten Partien) + const unlocked = + rsp?.counted && day.stats + ? newUnlocks(day.stats, rsp.stats, dailyMap(new Date()).day) + : [] + if (rsp?.counted) day.stats = rsp.stats + let status: Node if (!day.loggedIn) { const login = h('button', 'Log in') @@ -457,8 +492,16 @@ function showResult( `${played.lands}/9 lands`, ), status, + unlocked.length + ? h( + 'p.unlocked', + `🎁 Unlocked: ${unlocked.join(', ')} – choose it under 🎨 Realm`, + ) + : null, rsp ? leaderTable(rsp.top, rsp.result.rank) : null, - h('p.muted.small', 'Compare your realm in the comments below!'), + comment + ? h('div.row.center', comment) + : h('p.muted.small', 'Compare your realm in the comments below!'), h('div.row.center', share, again, close), ), ) diff --git a/src/client/realm.ts b/src/client/realm.ts new file mode 100644 index 0000000..5a0bcf0 --- /dev/null +++ b/src/client/realm.ts @@ -0,0 +1,164 @@ +// „🎨 Realm“: Aussehen wählen (Farbe, Volk, Thema, Banner). Gesperrtes zeigt seine Bedingung – das lockt zum +// Wiederkommen. Gespeichert wird in Redis (localStorage leert Devvit bei jedem App-Update). +import {showLoginPrompt} from '@devvit/web/client' +import {spriteUrl} from '../engine/app/board/atlas.ts' +import { + bannerPreview, + pieceSpriteName, + setBoardBanners, + setBoardLook, + setBoardTheme, +} from '../engine/app/board/BoardView.ts' +import {TEAM_HEX} from '../engine/core/avatars.ts' +import {Endpoint, type SetLookRsp} from '../shared/api.ts' +import { + BANNER_OPTIONS, + COLORS, + FACTION_OPTIONS, + isUnlocked, + type Look, + type Progress, + THEME_OPTIONS, + type Unlock, + unlockText, +} from '../shared/look.ts' +import {h} from './dom.ts' + +/** Aussehen auf alle Bretter anwenden (vor setBoard/update aufrufen) */ +export function applyLook(look: Look): void { + setBoardTheme(look.theme) + setBoardBanners([look.banner, look.banner]) + setBoardLook([look.color, look.color], [look.faction, look.faction]) +} + +const NAMES: Record = { + red: 'Red', + cyan: 'Cyan', + lime: 'Green', + purple: 'Purple', + kingdom: 'Kingdom', + orc: 'Orcs', + pirate: 'Pirates', + classic: 'Classic', + autumn: 'Autumn', + winter: 'Winter', + night: 'Night', + winterfest: 'Winterfest', + pennant: 'Pennant', + swallowtail: 'Swallowtail', + hanging: 'Hanging', + stripe: 'Stripe', + cross: 'Cross', + star: 'Star', + crown: 'Crown', + snowflake: 'Snowflake', +} + +function img(src: string): HTMLImageElement { + const el = h('img.realm-img') + el.src = src + el.alt = '' + return el +} + +function themeTile(theme: string): HTMLImageElement { + return img( + theme === 'classic' + ? spriteUrl('grass0') + : `/sprites/themes/${theme}/grass0.png`, + ) +} + +export function openRealm(opts: { + look: Look + progress: Progress + day: string + loggedIn: boolean + onChange: (look: Look) => void +}): void { + let look = opts.look + const body = h('div.realm-rows') + const close = h('button.primary', 'Done') + const overlay = h( + 'div.overlay', + h( + 'div.panel.realm', + h('h2', 'Your realm'), + h( + 'p.muted.small', + opts.loggedIn + ? 'Play daily to unlock more: themes come with your streak, banners and peoples with your stars.' + : 'Log in to customise your realm and keep your unlocks.', + ), + body, + h('div.row.end', close), + ), + ) + close.addEventListener('click', () => overlay.remove()) + + const row = ( + title: string, + options: readonly {id: T; unlock: Unlock}[], + key: keyof Look, + preview: (id: T) => Node, + ) => { + const items = h('div.realm-items') + for (const o of options) { + const open = isUnlocked(o.unlock, opts.progress, opts.day) + const btn = h( + 'button.realm-item', + preview(o.id), + h('span.realm-name', NAMES[o.id] ?? o.id), + open ? null : h('span.realm-lock', `🔒 ${unlockText(o.unlock)}`), + ) + btn.classList.toggle('selected', look[key] === o.id) + btn.classList.toggle('locked', !open) + btn.disabled = !open + btn.addEventListener('click', () => void choose({...look, [key]: o.id})) + items.append(btn) + } + return h('section.realm-row', h('h3', title), items) + } + + const render = () => { + body.replaceChildren( + row('Colour', COLORS, 'color', c => + img(spriteUrl(pieceSpriteName('HIVEMOTHER', c, look.faction))), + ), + row('People', FACTION_OPTIONS, 'faction', f => + img(spriteUrl(pieceSpriteName('HIVEMOTHER', look.color, f))), + ), + row('Board', THEME_OPTIONS, 'theme', t => themeTile(t)), + row('Banner', BANNER_OPTIONS, 'banner', b => + bannerPreview(b, TEAM_HEX[look.color]), + ), + ) + } + + async function choose(next: Look): Promise { + if (!opts.loggedIn) { + showLoginPrompt() + return + } + look = next + render() + try { + const rsp = await fetch(Endpoint.SetLook, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({look: next}), + }) + if (rsp.ok) look = ((await rsp.json()) as SetLookRsp).look + } catch { + // offline o. Ä.: Auswahl gilt bis zum Neuladen + } + render() + opts.onChange(look) + } + + render() + document.body.append(overlay) +} diff --git a/src/client/result.ts b/src/client/result.ts index e981074..3ad44fe 100644 --- a/src/client/result.ts +++ b/src/client/result.ts @@ -1,15 +1,9 @@ // Bausteine für Start- und Ergebnisansicht. import type {LeaderRow} from '../shared/api.ts' +import {starsLine} from '../shared/format.ts' import {h} from './dom.ts' -import {starsLine} from './texts.ts' -/** Teilen-Zeile wie im Hauptspiel: „Ninelands #12 ★★☆ · 9/9 lands · 85 gold“ */ -export function shareLine( - number: number, - r: {stars: number; gold: number; lands: number}, -): string { - return `Ninelands #${number} ${starsLine(r.stars)} · ${r.lands}/9 lands · ${r.gold} gold` -} +export {shareLine} from '../shared/format.ts' export function leaderTable(rows: LeaderRow[], myRank = 0): HTMLElement { if (rows.length === 0) diff --git a/src/client/splash.css b/src/client/splash.css index 76a5c86..30cfe60 100644 --- a/src/client/splash.css +++ b/src/client/splash.css @@ -32,6 +32,13 @@ min-width: 2rem; } +.head .realm-btn { + position: absolute; + top: 0; + left: 0; + font-size: 0.8rem; +} + .body { flex: 1; min-height: 0; @@ -88,6 +95,13 @@ /* Schmale Posts (Handy): Karte über den Infos */ @media (max-width: 30rem) { + .head .realm-btn { + position: absolute; + top: 0; + left: 0; + font-size: 0.8rem; + } + .body { flex-direction: column; } diff --git a/src/client/splash.ts b/src/client/splash.ts index 786952f..71dad5c 100644 --- a/src/client/splash.ts +++ b/src/client/splash.ts @@ -9,6 +9,7 @@ import {dailyBoard, dailyMap, dayLabel} from '../shared/daily.ts' import {fetchDay} from './api.ts' import {byId, h} from './dom.ts' import {appReady} from './journeys.ts' +import {applyLook, openRealm} from './realm.ts' import {leaderTable} from './result.ts' import {lawName, starsLine} from './texts.ts' @@ -31,9 +32,29 @@ async function main(): Promise { function render(day: GetDayRsp): void { const map = dailyMap(new Date(`${day.day}T00:00:00Z`)) + applyLook(day.look) const mini = h('div.mini') renderMiniBoard(mini, dailyBoard(map)) + const realm = h('button.icon.realm-btn', '🎨 Realm') + realm.title = 'Customise your realm' + realm.addEventListener('click', () => + openRealm({ + look: day.look, + progress: { + bestStreak: day.stats?.bestStreak ?? 0, + totalStars: day.stats?.totalStars ?? 0, + }, + day: dailyMap(new Date()).day, + loggedIn: day.loggedIn, + onChange: look => { + day.look = look + applyLook(look) + renderMiniBoard(mini, dailyBoard(map)) + }, + }), + ) + const play = h('button.primary', day.mine ? 'Play again' : 'Play') play.addEventListener('click', ev => requestExpandedMode(ev, 'game')) @@ -67,6 +88,7 @@ function render(day: GetDayRsp): void { 'p.muted.sub', `#${day.number} · ${dayLabel(day.day)}${day.today ? '' : ' · past map'}`, ), + realm, info, ), h( diff --git a/src/client/style.css b/src/client/style.css index 727ece5..0ca145e 100644 --- a/src/client/style.css +++ b/src/client/style.css @@ -217,3 +217,58 @@ button.icon { .row.center { justify-content: center; } + +/* ---------- Realm-Auswahl ---------- */ +.realm h3 { + margin: 0 0 0.25rem; + font-family: var(--font-pixel); + font-size: 0.95rem; + color: var(--px-parchment-dim); +} + +.realm-rows { + display: grid; + gap: 0.6rem; +} + +.realm-items { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.realm-item { + display: grid; + justify-items: center; + gap: 0.1rem; + width: 4.9rem; + padding: 0.3rem 0.2rem; + align-content: start; + font-size: 0.7rem; + background: var(--px-parchment-dim); + box-shadow: none; + border-width: 2px; +} + +.realm-item.selected { + background: var(--px-gold); + outline: 2px solid var(--px-parchment); +} + +.realm-item.locked { + opacity: 0.6; +} + +.realm-img, +.realm-item svg { + width: 32px; + height: 32px; + image-rendering: pixelated; +} + +.realm-lock { + font-family: var(--font-text); + font-size: 0.6rem; + line-height: 1.1; + text-align: center; +} diff --git a/src/client/texts.ts b/src/client/texts.ts index dfffe8d..ae2e1e4 100644 --- a/src/client/texts.ts +++ b/src/client/texts.ts @@ -119,6 +119,4 @@ export const HINTS = { allStars: 'All 3 stars! Keep going for more gold – or end the game.', } as const -export function starsLine(count: number): string { - return '★'.repeat(count) + '☆'.repeat(3 - count) -} +export {starsLine} from '../shared/format.ts' diff --git a/src/client/unlocks.ts b/src/client/unlocks.ts new file mode 100644 index 0000000..723c0d1 --- /dev/null +++ b/src/client/unlocks.ts @@ -0,0 +1,34 @@ +// Was eine gewertete Partie neu freigeschaltet hat (für die Ergebniskarte). +import { + BANNER_OPTIONS, + FACTION_OPTIONS, + isUnlocked, + type Progress, + THEME_OPTIONS, +} from '../shared/look.ts' + +const LABEL: Record = { + autumn: 'Autumn board', + winter: 'Winter board', + night: 'Night board', + orc: 'Orc people', + pirate: 'Pirate people', +} + +export function newUnlocks( + before: Progress, + after: Progress, + day: string, +): string[] { + const out: string[] = [] + for (const list of [THEME_OPTIONS, FACTION_OPTIONS, BANNER_OPTIONS] as const) + for (const o of list) + if ( + !isUnlocked(o.unlock, before, day) && + isUnlocked(o.unlock, after, day) + ) + out.push( + LABEL[o.id] ?? `${o.id[0]?.toUpperCase()}${o.id.slice(1)} banner`, + ) + return out +} diff --git a/src/server/db.ts b/src/server/db.ts index 6691fcc..7aa5b2c 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -4,7 +4,8 @@ // results:{day} Hash userId → JSON des ersten Versuchs (hSetNX = nur der erste zählt) // board:{day} Sorted Set userId → rankScore (Bestenliste) // seq:{day} Zähler der Abgaben (Gleichstand: frühere Abgabe gewinnt) -// player:{userId} JSON PlayerStats + lastDay +// player:{userId} JSON PlayerStats + lastDay, Aussehen, Kronen-Tag +// comments:{day} Hash userId → 1 (Ergebnis schon als Kommentar gepostet) // posts Hash day → postId (verhindert doppelte Tagesposts) // // Devvit-Regel „Enable and respect user deletions“: Für Kontolöschungen gibt es keinen Trigger, deshalb laufen alle @@ -13,6 +14,7 @@ import {redis} from '@devvit/web/server' import type {DayResult, Goals, LeaderRow, PlayerStats} from '../shared/api.ts' import {addDays} from '../shared/daily.ts' +import {type Look, sanitizeLook} from '../shared/look.ts' import {rankScore} from './verify.ts' type StoredResult = { @@ -22,7 +24,13 @@ type StoredResult = { lands: number at: number } -type StoredPlayer = PlayerStats & {lastDay: string} +type StoredPlayer = PlayerStats & { + lastDay: string + /** Gewähltes Aussehen (ungeprüft gespeichert, beim Lesen gegen die Freischaltungen geprüft) */ + look?: unknown + /** Tag, an dem der Spieler die Bestenliste angeführt hat (👑 im Flair am Folgetag) */ + crownDay?: string +} const TOP = 10 @@ -142,6 +150,75 @@ function visibleStats(p: StoredPlayer, today: string): PlayerStats { } } +async function savePlayer(userId: string, p: StoredPlayer): Promise { + await redis.set(`player:${userId}`, JSON.stringify(p)) + await redis.expire(`player:${userId}`, RETENTION_S) +} + +/** Aussehen des Spielers, geprüft gegen seine Freischaltungen (gesperrt → Standard) */ +export async function dbGetLook(userId: string, today: string): Promise { + const p = await loadPlayer(userId) + return sanitizeLook(p.look, p, today) +} + +export async function dbSetLook( + userId: string, + raw: unknown, + today: string, +): Promise { + const p = await loadPlayer(userId) + const look = sanitizeLook(raw, p, today) + // Wer noch nie gespielt hat, hat keinen Spieler-Eintrag – dann nur speichern, nichts zählen + p.look = look + await savePlayer(userId, p) + return look +} + +/** Bester Spieler eines Tages (für die Krone im Flair) */ +export async function dbWinner(day: string): Promise { + const rows = await redis.zRange(`board:${day}`, 0, 0, { + by: 'rank', + reverse: true, + }) + return rows[0]?.member +} + +export async function dbSetCrown( + userId: string, + day: string, +): Promise { + const p = await loadPlayer(userId) + p.crownDay = day + await savePlayer(userId, p) + return p +} + +/** Krone gilt am Tag nach dem Sieg */ +export function hasCrown(p: {crownDay?: string}, today: string): boolean { + return p.crownDay === addDays(today, -1) +} + +export async function dbGetPlayer(userId: string): Promise { + return loadPlayer(userId) +} + +export async function dbHasCommented( + day: string, + userId: string, +): Promise { + return Boolean(await redis.hGet(`comments:${day}`, userId)) +} + +/** Ergebnis als Kommentar: nur einmal je Spieler und Tag (hSetNX), damit niemand die Kommentare flutet */ +export async function dbClaimComment( + day: string, + userId: string, +): Promise { + const ok = await redis.hSetNX(`comments:${day}`, userId, '1') + await redis.expire(`comments:${day}`, RETENTION_S) + return ok === 1 +} + export async function dbGetStats( userId: string, today: string, @@ -167,8 +244,7 @@ export async function dbRecordPlay( p.bestStreak = Math.max(p.bestStreak, p.streak) p.lastDay = today } - await redis.set(`player:${userId}`, JSON.stringify(p)) - await redis.expire(`player:${userId}`, RETENTION_S) + await savePlayer(userId, p) return visibleStats(p, today) } diff --git a/src/server/server.test.ts b/src/server/server.test.ts index 0bf2aaa..916d8c4 100644 --- a/src/server/server.test.ts +++ b/src/server/server.test.ts @@ -2,15 +2,17 @@ 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, redis, runWithContext} from '@devvit/web/server' +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' @@ -83,12 +85,31 @@ const fake: Partial = { }, } +// 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( { @@ -107,6 +128,7 @@ before(async () => { 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())) @@ -120,6 +142,8 @@ beforeEach(() => { expiries.clear() hashes.clear() zsets.clear() + comments.length = 0 + flairs.length = 0 ctx = {userId: 't2_alice', username: 'alice', postData: {day: today}} }) @@ -265,6 +289,79 @@ test('früh beendete Partie wird gewertet', async () => { 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) diff --git a/src/server/server.ts b/src/server/server.ts index 2e86d48..4be3a25 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -7,26 +7,45 @@ import type { 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 {type DailyMap, dailyMap, dayLabel, isDay} from '../shared/daily.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' @@ -81,11 +100,21 @@ async function route( 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 @@ -134,13 +163,17 @@ async function routeGetDay(): Promise { const map = postMap() const today = dailyMap(new Date()).day const userId = context.userId - const [goals, top, players, mine, stats] = await Promise.all([ - goalsFor(map), - dbTop(map.day), - dbPlayers(map.day), - userId ? dbGetResult(map.day, userId) : null, - userId ? dbGetStats(userId, today) : null, - ]) + 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, @@ -153,6 +186,8 @@ async function routeGetDay(): Promise { stats, top, players, + look, + commented, } } @@ -184,6 +219,7 @@ async function routeSubmit(reqMsg: IncomingMessage): Promise { dbPlayers(map.day), ]) if (!mine) throw new HttpError(500, 'result missing') + if (counted) await updateFlair(userId, today) return { counted, result: mine, @@ -194,6 +230,74 @@ async function routeSubmit(reqMsg: IncomingMessage): Promise { } } +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()) diff --git a/src/shared/api.ts b/src/shared/api.ts index 97d9721..9405afa 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -1,3 +1,5 @@ +import type {Look} from './look.ts' + /** Fehler-Antwort aller Endpunkte */ export type ErrorRsp = {error: string; status: number} @@ -57,8 +59,16 @@ export type GetDayRsp = { stats: PlayerStats | null top: LeaderRow[] players: number + /** Eigenes Aussehen (Gäste: Standard) */ + look: Look + /** Schon als Kommentar gepostet? */ + commented: boolean } +export type SetLookReq = {look: Look} +export type SetLookRsp = {look: Look} +export type CommentRsp = {posted: boolean} + export type SubmitReq = {day: string; moves: EncodedMove[]} export type SubmitRsp = { /** false = es gab schon einen gewerteten ersten Versuch; result ist dann der alte */ @@ -75,6 +85,8 @@ export type Endpoint = (typeof Endpoint)[keyof typeof Endpoint] export const Endpoint = { GetDay: 'api/day', Submit: 'api/submit', + SetLook: 'api/look', + Comment: 'api/comment', OnAppInstall: 'internal/on/app/install', OnMenuNewPost: 'internal/on/menu/new-post', OnDailyPost: 'internal/scheduler/daily-post', @@ -89,6 +101,8 @@ export const Endpoint = { export const EndpointMethod = { [Endpoint.GetDay]: 'GET', [Endpoint.Submit]: 'POST', + [Endpoint.SetLook]: 'POST', + [Endpoint.Comment]: 'POST', [Endpoint.OnAppInstall]: 'POST', [Endpoint.OnMenuNewPost]: 'POST', [Endpoint.OnDailyPost]: 'POST', diff --git a/src/shared/format.test.ts b/src/shared/format.test.ts new file mode 100644 index 0000000..a688ece --- /dev/null +++ b/src/shared/format.test.ts @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict' +import {test} from 'node:test' +import {flairText, shareLine} from './format.ts' + +test('Flair: Krone und Streak, ohne Komma', () => { + assert.equal(flairText(false, 1), '') + assert.equal(flairText(false, 2), '🔥 2-day streak') + assert.equal(flairText(true, 0), '👑 Ruler of the day') + assert.equal(flairText(true, 12), '👑 Ruler of the day · 🔥 12-day streak') + assert.ok(!flairText(true, 12).includes(',')) +}) + +test('Teilen-Zeile', () => { + assert.equal( + shareLine(12, {stars: 2, gold: 85, lands: 8}), + 'Ninelands #12 ★★☆ · 8/9 lands · 85 gold', + ) +}) diff --git a/src/shared/format.ts b/src/shared/format.ts new file mode 100644 index 0000000..b18e243 --- /dev/null +++ b/src/shared/format.ts @@ -0,0 +1,22 @@ +// Texte, die Client und Server gleich bauen (Teilen-Zeile, Kommentar, Flair). + +export function starsLine(count: number): string { + return '★'.repeat(count) + '☆'.repeat(3 - count) +} + +/** Teilen-Zeile wie im Hauptspiel: „Ninelands #12 ★★☆ · 9/9 lands · 85 gold“ */ +export function shareLine( + number: number, + r: {stars: number; gold: number; lands: number}, +): string { + return `Ninelands #${number} ${starsLine(r.stars)} · ${r.lands}/9 lands · ${r.gold} gold` +} + +/** Flair: 👑 nach einem Tagessieg, 🔥 ab 2 Tagen Streak; leer = Flair nicht ändern. Kein Komma (Reddit-Vorgabe). */ +export function flairText(crown: boolean, streak: number): string { + const parts = [ + crown ? '👑 Ruler of the day' : '', + streak >= 2 ? `🔥 ${streak}-day streak` : '', + ].filter(Boolean) + return parts.join(' · ') +} diff --git a/src/shared/look.test.ts b/src/shared/look.test.ts new file mode 100644 index 0000000..eb4d154 --- /dev/null +++ b/src/shared/look.test.ts @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict' +import {test} from 'node:test' +import {BANNERS, THEMES} from '../engine/core/cosmetics.ts' +import { + BANNER_OPTIONS, + DEFAULT_LOOK, + isWinterfest, + sanitizeLook, + THEME_OPTIONS, +} from './look.ts' + +const none = {bestStreak: 0, totalStars: 0} +const lots = {bestStreak: 30, totalStars: 200} + +test('nur Themen und Banner, die es in der Engine gibt', () => { + for (const t of THEME_OPTIONS) assert.ok(THEMES.includes(t.id), t.id) + for (const b of BANNER_OPTIONS) assert.ok(BANNERS.includes(b.id), b.id) +}) + +test('Gesperrtes und Unbekanntes fällt auf den Standard zurück', () => { + const wanted = { + color: 'cyan', + faction: 'orc', + theme: 'night', + banner: 'crown', + } + assert.deepEqual(sanitizeLook(wanted, none, '2026-11-20'), { + ...DEFAULT_LOOK, + color: 'cyan', + }) + assert.deepEqual(sanitizeLook(wanted, lots, '2026-11-20'), wanted) + assert.deepEqual( + sanitizeLook({theme: 'hack'}, lots, '2026-11-20'), + DEFAULT_LOOK, + ) + assert.deepEqual(sanitizeLook(null, lots, '2026-11-20'), DEFAULT_LOOK) +}) + +test('Winterfest: 1. Dezember bis 6. Januar, auch während der Jury-Bewertung', () => { + assert.ok(isWinterfest('2026-12-01')) + assert.ok(isWinterfest('2026-12-16')) + assert.ok(isWinterfest('2027-01-06')) + assert.ok(!isWinterfest('2026-11-30')) + assert.ok(!isWinterfest('2027-01-07')) + assert.equal( + sanitizeLook({theme: 'winterfest'}, none, '2026-12-10').theme, + 'winterfest', + ) + assert.equal( + sanitizeLook({theme: 'winterfest'}, none, '2026-11-10').theme, + 'classic', + ) +}) diff --git a/src/shared/look.ts b/src/shared/look.ts new file mode 100644 index 0000000..500798b --- /dev/null +++ b/src/shared/look.ts @@ -0,0 +1,122 @@ +// Aussehen des eigenen Reichs (Phase 2): Figurenfarbe, Volk, Brett-Thema, Banner – nur Optik, gleiche Regeln. +// Freischaltungen hier eigenständig für Reddit (das Hauptspiel koppelt sie an Herrscher-Stufen und Kampagne): +// Farbe frei · Thema über die beste Streak · Banner und Volk über gesammelte Sterne · im Winterfest +// (1. Dez. bis 6. Jan.) Winterfest-Thema und Schneeflocken-Banner für alle. +// Browser und Server nutzen dieselben Regeln; der Server prüft jede Auswahl nach. +import { + type Faction, + TEAM_COLORS, + type TeamColor, +} from '../engine/core/avatars.ts' +import type {Banner, Theme} from '../engine/core/cosmetics.ts' + +export type Look = { + color: TeamColor + faction: Faction + theme: Theme + banner: Banner +} + +export const DEFAULT_LOOK: Look = { + color: 'red', + faction: 'kingdom', + theme: 'classic', + banner: 'classic', +} + +/** Was ein Spieler für Freischaltungen erreicht hat */ +export type Progress = {bestStreak: number; totalStars: number} + +/** Bedingung einer Freischaltung; text = Anzeige für gesperrte Einträge */ +export type Unlock = + | {kind: 'free'} + | {kind: 'streak'; days: number} + | {kind: 'stars'; stars: number} + | {kind: 'winterfest'} + +export const COLORS: readonly {id: TeamColor; unlock: Unlock}[] = + TEAM_COLORS.map(id => ({ + id, + unlock: {kind: 'free'}, + })) + +export const FACTION_OPTIONS: readonly {id: Faction; unlock: Unlock}[] = [ + {id: 'kingdom', unlock: {kind: 'free'}}, + {id: 'orc', unlock: {kind: 'stars', stars: 20}}, + {id: 'pirate', unlock: {kind: 'stars', stars: 40}}, +] + +export const THEME_OPTIONS: readonly {id: Theme; unlock: Unlock}[] = [ + {id: 'classic', unlock: {kind: 'free'}}, + {id: 'autumn', unlock: {kind: 'streak', days: 3}}, + {id: 'winter', unlock: {kind: 'streak', days: 7}}, + {id: 'night', unlock: {kind: 'streak', days: 14}}, + {id: 'winterfest', unlock: {kind: 'winterfest'}}, +] + +/** Ein neues Banner je 10 Sterne; Schneeflocke im Winterfest */ +export const BANNER_OPTIONS: readonly {id: Banner; unlock: Unlock}[] = [ + {id: 'classic', unlock: {kind: 'free'}}, + {id: 'pennant', unlock: {kind: 'stars', stars: 10}}, + {id: 'swallowtail', unlock: {kind: 'stars', stars: 20}}, + {id: 'hanging', unlock: {kind: 'stars', stars: 30}}, + {id: 'stripe', unlock: {kind: 'stars', stars: 40}}, + {id: 'cross', unlock: {kind: 'stars', stars: 50}}, + {id: 'star', unlock: {kind: 'stars', stars: 60}}, + {id: 'crown', unlock: {kind: 'stars', stars: 80}}, + {id: 'snowflake', unlock: {kind: 'winterfest'}}, +] + +/** Winterfest der Reddit-Fassung: 1. Dezember bis 6. Januar (UTC), also während der Jury-Bewertung */ +export function isWinterfest(day: string): boolean { + const md = day.slice(5) + return md >= '12-01' || md <= '01-06' +} + +export function isUnlocked(u: Unlock, p: Progress, day: string): boolean { + switch (u.kind) { + case 'free': + return true + case 'streak': + return p.bestStreak >= u.days + case 'stars': + return p.totalStars >= u.stars + case 'winterfest': + return isWinterfest(day) + } +} + +export function unlockText(u: Unlock): string { + switch (u.kind) { + case 'free': + return '' + case 'streak': + return `${u.days}-day streak` + case 'stars': + return `${u.stars} ★` + case 'winterfest': + return 'Dec 1 – Jan 6' + } +} + +/** Auswahl prüfen: nur bekannte und freigeschaltete Werte; Unbekanntes fällt auf den Standard zurück */ +export function sanitizeLook(raw: unknown, p: Progress, day: string): Look { + const r = (raw && typeof raw === 'object' ? raw : {}) as Record< + string, + unknown + > + const pick = ( + options: readonly {id: T; unlock: Unlock}[], + value: unknown, + fallback: T, + ): T => { + const o = options.find(x => x.id === value) + return o && isUnlocked(o.unlock, p, day) ? o.id : fallback + } + return { + color: pick(COLORS, r.color, DEFAULT_LOOK.color), + faction: pick(FACTION_OPTIONS, r.faction, DEFAULT_LOOK.faction), + theme: pick(THEME_OPTIONS, r.theme, DEFAULT_LOOK.theme), + banner: pick(BANNER_OPTIONS, r.banner, DEFAULT_LOOK.banner), + } +}