Merge branch 'feat/phase2-look'
All checks were successful
CI / test (push) Successful in 22s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lukas Müllner 2026-11-13 17:33:53 +01:00
commit 2f12ae4ca1
19 changed files with 891 additions and 28 deletions

21
PLAN.md
View file

@ -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.

View file

@ -16,7 +16,11 @@
"server": {},
"permissions": {
"redis": true,
"journeys": true
"journeys": true,
"reddit": {
"enable": true,
"asUser": ["SUBMIT_COMMENT"]
}
},
"menu": {
"items": [

View file

@ -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;
}

View file

@ -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<HTMLDivElement>('app')
@ -100,6 +107,7 @@ async function main(): Promise<void> {
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),
),
)

164
src/client/realm.ts Normal file
View file

@ -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<string, string> = {
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 = <T extends string>(
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<void> {
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)
}

View file

@ -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)

View file

@ -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;
}

View file

@ -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<void> {
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(

View file

@ -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;
}

View file

@ -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'

34
src/client/unlocks.ts Normal file
View file

@ -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<string, string> = {
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
}

View file

@ -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<void> {
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<Look> {
const p = await loadPlayer(userId)
return sanitizeLook(p.look, p, today)
}
export async function dbSetLook(
userId: string,
raw: unknown,
today: string,
): Promise<Look> {
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<string | undefined> {
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<StoredPlayer> {
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<StoredPlayer> {
return loadPlayer(userId)
}
export async function dbHasCommented(
day: string,
userId: string,
): Promise<boolean> {
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<boolean> {
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)
}

View file

@ -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<typeof redis> = {
},
}
// 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<void>((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<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)

View file

@ -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<GetDayRsp> {
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<GetDayRsp> {
stats,
top,
players,
look,
commented,
}
}
@ -184,6 +219,7 @@ async function routeSubmit(reqMsg: IncomingMessage): Promise<SubmitRsp> {
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<SubmitRsp> {
}
}
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(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<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())

View file

@ -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',

18
src/shared/format.test.ts Normal file
View file

@ -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',
)
})

22
src/shared/format.ts Normal file
View file

@ -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(' · ')
}

53
src/shared/look.test.ts Normal file
View file

@ -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',
)
})

122
src/shared/look.ts Normal file
View file

@ -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 = <T extends string>(
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),
}
}