feat: Realm-Auswahl (Farbe, Volk, Thema, Banner), Freischaltungen, Flair, Ergebnis als Kommentar
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
90067bc2e1
commit
51d47cd0d0
19 changed files with 891 additions and 28 deletions
21
PLAN.md
21
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.
|
||||
|
|
|
|||
|
|
@ -16,7 +16,11 @@
|
|||
"server": {},
|
||||
"permissions": {
|
||||
"redis": true,
|
||||
"journeys": true
|
||||
"journeys": true,
|
||||
"reddit": {
|
||||
"enable": true,
|
||||
"asUser": ["SUBMIT_COMMENT"]
|
||||
}
|
||||
},
|
||||
"menu": {
|
||||
"items": [
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
164
src/client/realm.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
34
src/client/unlocks.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
18
src/shared/format.test.ts
Normal 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
22
src/shared/format.ts
Normal 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
53
src/shared/look.test.ts
Normal 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
122
src/shared/look.ts
Normal 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),
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue