Merge branch 'feat/nummer-pro-subreddit'
All checks were successful
CI / test (push) Successful in 23s
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:
commit
e09dc0eaa4
6 changed files with 47 additions and 15 deletions
8
PLAN.md
8
PLAN.md
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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', () => {
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue