Merge branch 'feat/nummer-pro-subreddit'
All checks were successful
CI / test (push) Successful in 23s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lukas Müllner 2026-11-13 18:30:59 +01:00
commit e09dc0eaa4
6 changed files with 47 additions and 15 deletions

View file

@ -260,3 +260,11 @@ Abgabe: **9. Dezember 2026, 03:00 MEZ**. Eingereicht wird in „Best Daily Game
Build gab es zweimal doppelte Versionsnummern und Abbruch. Bei Änderungen an `devvit.json` Playtest stoppen und neu Build gab es zweimal doppelte Versionsnummern und Abbruch. Bei Änderungen an `devvit.json` Playtest stoppen und neu
starten. starten.
- Folge der 30-Tage-Regel: Freischaltungen gehen nach 30 Tagen ohne Spiel verloren. - Folge der 30-Tage-Regel: Freischaltungen gehen nach 30 Tagen ohne Spiel verloren.
### 2026-11-13 – Nummer je Subreddit
- „Ninelands #N“ zählt **je Subreddit ab dem ersten Tag dort** (`firstDay` in Redis, beim ersten Aufruf per `nx`
gesetzt, enthält keine Nutzerdaten und läuft nicht ab). So beginnt `r/Ninelands` mit #1, auch wenn es erst im
Dezember startet. Die Karte hängt weiter nur am Datum (alle spielen am selben Tag dieselbe). `FIRST_DAY` entfällt.
- Plan Besitzer/Claude: `r/ninelands_game_dev` bleibt Test-Subreddit; `r/Ninelands` (Name frei) etwa eine Woche vor
der Abgabe anlegen (≈ 1. Dez.), öffentlich oder „Restricted“, < 200 Mitglieder bis zur Bewertung.

View file

@ -7,6 +7,7 @@
// player:{userId} JSON PlayerStats + lastDay, Aussehen, Kronen-Tag // player:{userId} JSON PlayerStats + lastDay, Aussehen, Kronen-Tag
// comments:{day} Hash userId → 1 (Ergebnis schon als Kommentar gepostet) // comments:{day} Hash userId → 1 (Ergebnis schon als Kommentar gepostet)
// posts Hash day → postId (verhindert doppelte Tagesposts) // posts Hash day → postId (verhindert doppelte Tagesposts)
// firstDay erster Tag in diesem Subreddit (Nummer „#N“ zählt ab hier; enthält keine Nutzerdaten)
// //
// Devvit-Regel „Enable and respect user deletions“: Für Kontolöschungen gibt es keinen Trigger, deshalb laufen alle // Devvit-Regel „Enable and respect user deletions“: Für Kontolöschungen gibt es keinen Trigger, deshalb laufen alle
// Schlüssel mit User-ID oder Namen nach RETENTION_S ab (Reddit empfiehlt ≤ 30 Tage). Jede Abgabe verlängert die // Schlüssel mit User-ID oder Namen nach RETENTION_S ab (Reddit empfiehlt ≤ 30 Tage). Jede Abgabe verlängert die
@ -255,3 +256,9 @@ export async function dbGetPostId(day: string): Promise<string | undefined> {
export async function dbSetPostId(day: string, postId: string): Promise<void> { export async function dbSetPostId(day: string, postId: string): Promise<void> {
await redis.hSet('posts', {[day]: postId}) await redis.hSet('posts', {[day]: postId})
} }
/** Erster Tag in diesem Subreddit; beim ersten Aufruf auf heute gesetzt (nx: nie überschreiben) */
export async function dbFirstDay(today: string): Promise<string> {
await redis.set('firstDay', today, {nx: true})
return (await redis.get('firstDay')) ?? today
}

View file

@ -50,7 +50,8 @@ const fake: Partial<typeof redis> = {
expiries.set(k, seconds) expiries.set(k, seconds)
}, },
get: async k => strings.get(k), get: async k => strings.get(k),
set: async (k, v) => { set: async (k, v, opts) => {
if (opts?.nx && strings.has(k)) return ''
strings.set(k, v) strings.set(k, v)
return 'OK' return 'OK'
}, },
@ -362,6 +363,12 @@ test('Flair: Streak ab 2 Tagen', async () => {
assert.deepEqual(flairs.at(-1)?.text, '🔥 5-day streak') assert.deepEqual(flairs.at(-1)?.text, '🔥 5-day streak')
}) })
test('Nummer zählt ab dem ersten Tag im Subreddit', async () => {
assert.equal((await getDay()).number, 1)
strings.set('firstDay', addDays(today, -9))
assert.equal((await getDay()).number, 10)
})
test('404', async () => { test('404', async () => {
const rsp = await fetch(serverURL) const rsp = await fetch(serverURL)
assert.equal(rsp.status, 404) assert.equal(rsp.status, 404)

View file

@ -23,12 +23,14 @@ import {
type DailyMap, type DailyMap,
dailyMap, dailyMap,
dayLabel, dayLabel,
dayNumber,
isDay, isDay,
} from '../shared/daily.ts' } from '../shared/daily.ts'
import {flairText, shareLine} from '../shared/format.ts' import {flairText, shareLine} from '../shared/format.ts'
import {DEFAULT_LOOK} from '../shared/look.ts' import {DEFAULT_LOOK} from '../shared/look.ts'
import { import {
dbClaimComment, dbClaimComment,
dbFirstDay,
dbGetGoals, dbGetGoals,
dbGetLook, dbGetLook,
dbGetPlayer, dbGetPlayer,
@ -176,7 +178,7 @@ async function routeGetDay(): Promise<GetDayRsp> {
) )
return { return {
day: map.day, day: map.day,
number: map.number, number: dayNumber(await dbFirstDay(today), map.day),
mapId: map.mapId, mapId: map.mapId,
trait: map.trait, trait: map.trait,
goals, goals,
@ -252,7 +254,7 @@ async function routeComment(): Promise<CommentRsp> {
if (!(await dbClaimComment(map.day, userId))) return {posted: false} if (!(await dbClaimComment(map.day, userId))) return {posted: false}
await reddit.submitComment({ await reddit.submitComment({
id: postId, id: postId,
text: `${shareLine(map.number, mine)} · #${mine.rank} today`, text: `${shareLine(dayNumber(await dbFirstDay(map.day), map.day), mine)} · #${mine.rank} today`,
runAs: 'USER', runAs: 'USER',
}) })
return {posted: true} return {posted: true}
@ -304,7 +306,7 @@ async function ensureDailyPost(): Promise<{id: string; created: boolean}> {
const existing = await dbGetPostId(map.day) const existing = await dbGetPostId(map.day)
if (existing) return {id: existing, created: false} if (existing) return {id: existing, created: false}
const post = await reddit.submitCustomPost({ const post = await reddit.submitCustomPost({
title: `Ninelands #${map.number} · ${dayLabel(map.day)}`, title: `Ninelands #${dayNumber(await dbFirstDay(map.day), map.day)} · ${dayLabel(map.day)}`,
entry: 'default', entry: 'default',
postData: {day: map.day}, postData: {day: map.day},
}) })

View file

@ -5,7 +5,7 @@ import {Game} from '../engine/core/Game.ts'
import {computeGoals} from '../engine/core/goals.ts' import {computeGoals} from '../engine/core/goals.ts'
import {applyMoves, encodeMoves} from '../engine/core/replay.ts' import {applyMoves, encodeMoves} from '../engine/core/replay.ts'
import {dailySeed, mulberry32} from '../engine/core/rng.ts' import {dailySeed, mulberry32} from '../engine/core/rng.ts'
import {dailyBoard, dailyMap} from './daily.ts' import {dailyBoard, dailyMap, dayNumber} from './daily.ts'
test('Tageskarte ist deterministisch und je Tag verschieden', () => { test('Tageskarte ist deterministisch und je Tag verschieden', () => {
const a = dailyMap(new Date('2026-11-20T00:00:00Z')) const a = dailyMap(new Date('2026-11-20T00:00:00Z'))
@ -13,8 +13,13 @@ test('Tageskarte ist deterministisch und je Tag verschieden', () => {
const c = dailyMap(new Date('2026-11-21T12:00:00Z')) const c = dailyMap(new Date('2026-11-21T12:00:00Z'))
assert.deepEqual(a, b) assert.deepEqual(a, b)
assert.notEqual(a.seed, c.seed) assert.notEqual(a.seed, c.seed)
assert.equal(c.number, a.number + 1) })
assert.equal(dailyMap(new Date('2026-11-13T05:00:00Z')).number, 1)
test('Nummer zählt je Subreddit ab dem ersten Tag dort', () => {
assert.equal(dayNumber('2026-12-01', '2026-12-01'), 1)
assert.equal(dayNumber('2026-12-01', '2026-12-09'), 9)
assert.equal(dayNumber('2026-11-13', '2027-01-01'), 50)
assert.equal(dayNumber('2026-12-01', '2026-11-20'), 1) // alte Karte vor dem Start: nie 0 oder negativ
}) })
test('Reddit-Seed unterscheidet sich von der Website-Tageskarte', () => { test('Reddit-Seed unterscheidet sich von der Website-Tageskarte', () => {

View file

@ -8,14 +8,9 @@ import type {WorldTrait} from '../engine/core/WorldRules.ts'
/** Präfix des Tages-Seeds. Nie ändern – sonst ändern sich alle Tageskarten (und Bestenlisten) rückwirkend. */ /** Präfix des Tages-Seeds. Nie ändern – sonst ändern sich alle Tageskarten (und Bestenlisten) rückwirkend. */
export const DAILY_SEED_PREFIX = 'ninelands-reddit-daily-' export const DAILY_SEED_PREFIX = 'ninelands-reddit-daily-'
/** Erster Tag des Reddit-Spiels (UTC); daraus die laufende Nummer „Ninelands #N“. */
export const FIRST_DAY = '2026-11-13'
export interface DailyMap { export interface DailyMap {
/** UTC-Tag „JJJJ-MM-TT“ */ /** UTC-Tag „JJJJ-MM-TT“ */
day: string day: string
/** laufende Nummer ab FIRST_DAY (1 = erster Tag) */
number: number
seed: number seed: number
mapId: string mapId: string
trait: WorldTrait trait: WorldTrait
@ -24,11 +19,8 @@ export interface DailyMap {
export function dailyMap(date: Date): DailyMap { export function dailyMap(date: Date): DailyMap {
const day = dailyKey(date) const day = dailyKey(date)
const seed = fnv1a(`${DAILY_SEED_PREFIX}${day}`) const seed = fnv1a(`${DAILY_SEED_PREFIX}${day}`)
const number =
Math.round((Date.parse(day) - Date.parse(FIRST_DAY)) / 86_400_000) + 1
return { return {
day, day,
number,
seed, seed,
mapId: mapCodeFromSeed(seed), mapId: mapCodeFromSeed(seed),
trait: rolledTrait(seed), trait: rolledTrait(seed),
@ -40,6 +32,17 @@ export function dailyBoard(map: DailyMap): Board {
return generateBoard(map.seed, {trait: map.trait}) return generateBoard(map.seed, {trait: map.trait})
} }
/**
* Laufende Nummer „Ninelands #N“: zählt je Subreddit ab dem ersten Tag dort (1 = erster Tag), damit z. B.
* r/Ninelands mit #1 beginnt. Die Karte selbst hängt nur am Datum – alle spielen am selben Tag dieselbe.
*/
export function dayNumber(firstDay: string, day: string): number {
return Math.max(
1,
Math.round((Date.parse(day) - Date.parse(firstDay)) / 86_400_000) + 1,
)
}
/** Gültiger UTC-Tag „JJJJ-MM-TT“ (aus Post-Daten oder Anfragen) */ /** Gültiger UTC-Tag „JJJJ-MM-TT“ (aus Post-Daten oder Anfragen) */
export function isDay(value: unknown): value is string { export function isDay(value: unknown): value is string {
return ( return (