diff --git a/PLAN.md b/PLAN.md index 371d85b..79c76a0 100644 --- a/PLAN.md +++ b/PLAN.md @@ -185,3 +185,15 @@ Abgabe: **9. Dezember 2026, 03:00 MEZ**. Eingereicht wird in „Best Daily Game `localStorage` (geht bei App-Updates verloren – vertretbar; Redis erst, wenn es stört). - Lokale Vorschau ohne Reddit: kleine Server-Attrappe + Playwright-Screenshots (Skripte im Scratchpad, nicht im Repo). - Biome ergänzt bei `--write` Dateiendungen an Engine-Importen (`'../engine/core/Game.ts'`) – ist so gewollt. + +### 2026-11-13 – Spielende und Ziel-Hervorhebung (Rückmeldung Besitzer nach erstem Playtest) + +- Problem: Man wusste nicht, wie man die Partie beendet – sie endete nur, wenn kein Zug mehr möglich war (Bauern + sind gratis, das dauert). +- **„End game“-Knopf** wie im Hauptspiel, mit zweitem Tipp zur Bestätigung (3 s). Der Server wertet jetzt auch früh + beendete Partien; abgelehnt wird nur eine leere (`EMPTY`). Kein Vorteil durch frühes Aufhören: Gold zählt vor der + Abgabezeit. +- **Kein automatisches Ende bei 3 Sternen** – unter Spielern mit 3 Sternen entscheidet das Gold; stattdessen Hinweis + „All 3 stars! Keep going for more gold – or end the game.“ +- Erreichte Ziele werden **golden** hervorgehoben (wie `goal-chip.done` im Hauptspiel). +- `devvit playtest` trägt `dev.subreddit` (`ninelands_game_dev`) in `devvit.json` ein. diff --git a/devvit.json b/devvit.json index 951b3f2..f8d85cd 100644 --- a/devvit.json +++ b/devvit.json @@ -41,5 +41,8 @@ }, "scripts": { "dev": "npm run watch" + }, + "dev": { + "subreddit": "ninelands_game_dev" } } diff --git a/src/client/game.css b/src/client/game.css index c9aeee0..c2f6cb0 100644 --- a/src/client/game.css +++ b/src/client/game.css @@ -44,6 +44,22 @@ } .goal-star { + color: var(--px-ink); +} + +.goal .of { + color: var(--px-parchment-dim); +} + +/* Erreichtes Ziel: golden wie im Hauptspiel */ +.goal.done { + color: var(--px-ink); + background: var(--px-gold); + box-shadow: inset 0 -3px 0 var(--px-gold-dark); +} + +.goal.done .of, +.goal.done .goal-star { color: var(--px-gold); font-family: var(--font-pixel); margin-right: 0.2rem; @@ -148,10 +164,28 @@ opacity: 0.8; } -.toolbar > button.icon { +.actions { + display: grid; + gap: 0.35rem; + grid-template-rows: 1fr auto; +} + +.actions button.icon { font-size: 1.4rem; } +button.end { + font-size: 0.65rem; + padding: 0.2rem 0.3rem; + background: var(--px-parchment-dim); + white-space: nowrap; +} + +button.end.armed { + background: var(--px-red); + color: var(--px-parchment); +} + /* Ergebnis */ .result { text-align: center; diff --git a/src/client/game.ts b/src/client/game.ts index a33cb1e..b628e24 100644 --- a/src/client/game.ts +++ b/src/client/game.ts @@ -52,10 +52,34 @@ const goldStar = h('span.goal-star') const landsStar = h('span.goal-star') const bothStar = h('span.goal-star') const undoBtn = h('button.icon', '↶') +const endBtn = h('button.end', 'End game') +const goldGoal = h('span.goal') +const landsGoal = h('span.goal') +const bothGoal = h('span.goal') +/** „End game“ braucht einen zweiten Tipp; nach 3 s ohne Bestätigung zurück */ +let endArmed: ReturnType | null = null const toolButtons = new Map() view.listen({click: onCellClick, hover: onHover}) undoBtn.addEventListener('click', undo) +endBtn.addEventListener('click', () => { + if (finished) return + if (!endArmed) { + endBtn.textContent = 'Sure? Tap again' + endBtn.classList.add('armed') + endArmed = setTimeout(disarmEnd, 3000) + return + } + disarmEnd() + void finish() +}) + +function disarmEnd(): void { + if (endArmed) clearTimeout(endArmed) + endArmed = null + endBtn.textContent = 'End game' + endBtn.classList.remove('armed') +} async function main(): Promise { app.replaceChildren(h('div.loading', 'Loading the nine lands…')) @@ -75,6 +99,7 @@ function start(): void { game = new Game(dailyBoard(map), {undoLimit: 1}) tool = 'HIVEMOTHER' finished = false + disarmEnd() view.setBoard(game.board) view.setLandNames(ZONE_NAMES) render() @@ -125,21 +150,21 @@ function render(): void { ), h( 'div.goals', - h( - 'span.goal', + fill( + goldGoal, goldStar, '🪙 ', goldEl, - h('span.muted', ` / ${day.goals.gold}`), + h('span.of', ` / ${day.goals.gold}`), ), - h( - 'span.goal', + fill( + landsGoal, landsStar, '🏰 ', landsEl, - h('span.muted', ` / ${day.goals.lands} lands`), + h('span.of', ` / ${day.goals.lands} lands`), ), - h('span.goal', bothStar, h('span.muted', 'both')), + fill(bothGoal, bothStar, h('span.of', 'both')), ), practice ? h( @@ -151,12 +176,17 @@ function render(): void { : null, law, h('div.board-area', host, hint), - h('div.toolbar', tools, undoBtn), + h('div.toolbar', tools, h('div.actions', undoBtn, endBtn)), ), ) update() } +function fill(el: HTMLElement, ...children: (Node | string)[]): HTMLElement { + el.replaceChildren(...children) + return el +} + function spriteImg(unit: UnitType): HTMLImageElement { const img = h('img.tool-sprite') img.src = spriteUrl(pieceSpriteName(unit, 'red', 'kingdom')) @@ -224,7 +254,12 @@ function update(): void { goldStar.textContent = stars.gold ? '★' : '☆' landsStar.textContent = stars.lands ? '★' : '☆' bothStar.textContent = stars.both ? '★' : '☆' + // Erreichte Ziele golden wie im Hauptspiel (goal-chip.done) + goldGoal.classList.toggle('done', stars.gold) + landsGoal.classList.toggle('done', stars.lands) + bothGoal.classList.toggle('done', stars.both) undoBtn.disabled = finished || !game.canUndo + endBtn.disabled = finished || game.moves.length === 0 const hasCastle = game.count('HIVEMOTHER') > 0 for (const [unit, btn] of toolButtons) { btn.classList.toggle('active', unit === tool) @@ -285,7 +320,8 @@ function renderHint(): void { ) return } - if (game.count('HIVEMOTHER') === 0) setHint(HINTS.castle) + if (starsOf(game, day.goals).both) setHint(HINTS.allStars, 'good') + else if (game.count('HIVEMOTHER') === 0) setHint(HINTS.castle) else if (game.moves.length < 3) setHint(HINTS.farmer) else setHint(HINTS.general) } @@ -297,8 +333,12 @@ function setHint(text: string, tone: 'good' | 'bad' | 'warn' | '' = ''): void { async function finish(): Promise { finished = true + disarmEnd() update() - setHint('No move left – the realm is complete!', 'good') + setHint( + game.isGameOver() ? 'No move left – the realm is complete!' : 'Game ended.', + 'good', + ) const moves = encodeMoves(game.moves) as EncodedMove[] const stars = starsOf(game, day.goals).count const local = { diff --git a/src/client/texts.ts b/src/client/texts.ts index 1206f0c..2b36f6f 100644 --- a/src/client/texts.ts +++ b/src/client/texts.ts @@ -114,6 +114,7 @@ export const HINTS = { general: 'Ore ⛰ and herbs 🌿 pay 20 gold. Knights pay double. Keep going until no move is left.', touch: 'Tap again to place.', + allStars: 'All 3 stars! Keep going for more gold – or end the game.', } as const export function starsLine(count: number): string { diff --git a/src/server/server.test.ts b/src/server/server.test.ts index 0d4d7ee..c37c0af 100644 --- a/src/server/server.test.ts +++ b/src/server/server.test.ts @@ -228,7 +228,7 @@ test('Bestenliste: bei Gleichstand gewinnt die frühere Abgabe', async () => { test('ungültige Abgaben werden abgelehnt', async () => { const moves = botMoves(today, 1) const cases: [unknown, number][] = [ - [{day: today, moves: moves.slice(0, 2)}, 400], // Partie nicht fertig + [{day: today, moves: []}, 400], // leere Partie [{day: today, moves: [['M', 999]]}, 400], // Feld gibt es nicht [{day: addDays(today, -1), moves}, 400], // falscher Tag ] @@ -241,6 +241,12 @@ test('ungültige Abgaben werden abgelehnt', async () => { assert.equal((await getDay()).players, 0) }) +test('früh beendete Partie wird gewertet', async () => { + const rsp = await submit({day: today, moves: botMoves(today, 1).slice(0, 3)}) + assert.equal(rsp.status, 200) + assert.equal(((await rsp.json()) as SubmitRsp).counted, true) +}) + test('404', async () => { const rsp = await fetch(serverURL) assert.equal(rsp.status, 404) diff --git a/src/server/verify.ts b/src/server/verify.ts index dad1bab..63c7cc7 100644 --- a/src/server/verify.ts +++ b/src/server/verify.ts @@ -14,7 +14,7 @@ export function goalsOf(map: DailyMap): Goals { export type Verified = | {ok: true; gold: number; lands: number; stars: number} - | {ok: false; error: 'INVALID_MOVES' | 'NOT_FINISHED'} + | {ok: false; error: 'INVALID_MOVES' | 'EMPTY'} export function verifyGame( map: DailyMap, @@ -24,8 +24,9 @@ export function verifyGame( // Gleiche Optionen wie im Client; undoLimit ist egal, nachgespielt wird nur die endgültige Zugfolge const game = new Game(dailyBoard(map), {undoLimit: 0}) if (!applyMoves(game, moves)) return {ok: false, error: 'INVALID_MOVES'} - // Gewertet wird nur eine fertige Partie (kein Zug mehr möglich) – sonst könnte man früh aufhören, um Zeit zu sparen - if (!game.isGameOver()) return {ok: false, error: 'NOT_FINISHED'} + // Früh beenden ist erlaubt („End game“): wer aufhört, hat nur weniger Gold – Gold zählt vor der Abgabezeit. + // Eine leere Partie (nicht einmal die Burg) wird nicht gewertet. + if (game.moves.length === 0) return {ok: false, error: 'EMPTY'} return { ok: true, gold: game.score,