feat: End-game-Knopf, früh beendete Partien werten, erreichte Ziele golden

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lukas Müllner 2026-11-13 16:47:59 +01:00
parent 7623a0a963
commit 9bc133a08c
7 changed files with 112 additions and 15 deletions

12
PLAN.md
View file

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

View file

@ -41,5 +41,8 @@
},
"scripts": {
"dev": "npm run watch"
},
"dev": {
"subreddit": "ninelands_game_dev"
}
}

View file

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

View file

@ -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<typeof setTimeout> | null = null
const toolButtons = new Map<UnitType, HTMLButtonElement>()
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<void> {
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<void> {
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 = {

View file

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

View file

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

View file

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