Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
426365f502
7 changed files with 112 additions and 15 deletions
12
PLAN.md
12
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.
|
||||
|
|
|
|||
|
|
@ -41,5 +41,8 @@
|
|||
},
|
||||
"scripts": {
|
||||
"dev": "npm run watch"
|
||||
},
|
||||
"dev": {
|
||||
"subreddit": "ninelands_game_dev"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue