diff --git a/CLAUDE.md b/CLAUDE.md index a8eb69e..074e839 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,6 +87,9 @@ Abrufe (403). anspricht, in der Devvit-Doku prüfen (https://developers.reddit.com/docs). - Aktuelle Devvit-Doku lesen, bevor etwas gebaut wird – die Plattform ändert sich schnell; nicht aus dem Gedächtnis arbeiten. +- **Engine-Kopie:** `scripts/sync-engine.sh ` (Stand in `src/engine/VERSION`), holt auch Sprites und + Musik nach `public/`. `src/engine/` nie von Hand ändern; Biome ignoriert es. Unit-Tests werden vor `node --test` mit + esbuild gebündelt (die Engine importiert ohne Dateiendung). - `.github/workflows/ci.yaml` stammt aus der Vorlage. Forgejo führt `.github/workflows` aus, wenn es kein `.forgejo/workflows` gibt; `runs-on: ubuntu-latest` ist der gemeinsame Hetzner-Runner (für `npm run test` reicht er). @@ -118,7 +121,7 @@ Abrufe (403). - CI: Die `parallel:`-Stufe der Vorlage ersetzt (Forgejo-Runner v13: „Invalid run/uses syntax“), Schritte laufen nacheinander. - `PLAN.md` angelegt, Lizenz auf GPL-3.0 umgestellt. -- Nächste Schritte: offene Fragen in `PLAN.md` klären, dann Phase 0 (Sync-Skript, Engine, Assets). +- Phase 0 erledigt (Engine `v0.11.0`, Tageskarte `src/shared/daily.ts`, Assets). Nächster Schritt: Phase 1. - Subreddit `r/Ninelands` ist frei (Besitzer hat nachgesehen); anlegen erst für den Demo-Post. Diese Datei bei neuen Arbeitsregeln und Entscheidungen aktualisieren. diff --git a/PLAN.md b/PLAN.md index f27fa05..39e2f2a 100644 --- a/PLAN.md +++ b/PLAN.md @@ -90,6 +90,16 @@ Abgabe: **9. Dezember 2026, 03:00 MEZ**. Eingereicht wird in „Best Daily Game legt der Besitzer erst für den Demo-Post an. Hochgeladene, nicht veröffentlichte Apps lassen sich nur in Subreddits mit < 200 Mitgliedern installieren – für die Jam-Demo reicht das. +## Devvit-Vorgaben, die das Design betreffen (Doku, 2026-11-13) + +- **Ton:** erst nach einer Nutzeraktion abspielen, **Stumm-Knopf** im Spiel, bei `visibilitychange` stumm schalten. +- **Gesten:** nur Tippen/Klicken; kein Scroll-Abfangen, kein Zoom/Pan – man muss am Post vorbeiscrollen können. +- **Expanded Mode** nur auf ausdrückliche Aktion (Knopf), nie automatisch; die Standardansicht bleibt im Post. +- **Responsive**, die meisten Spieler sind mobil. +- **`localStorage` wird bei jedem App-Update gelöscht** → Streak, Freischaltungen, gewähltes Thema, Stumm-Schalter in + Redis speichern. +- Server: serverlos, max. 30 s je Anfrage, 4 MB Anfrage, 10 MB Antwort; kein `fs`. Client: keine externen Abrufe. + ## Risiken - Devvit ändert sich schnell: Scheduler, Redis, Flair und Journeys vor dem Bauen in der aktuellen Doku lesen @@ -109,3 +119,24 @@ Abgabe: **9. Dezember 2026, 03:00 MEZ**. Eingereicht wird in „Best Daily Game keinen Zeilenumbruch am Ende. - Lizenz auf GPL-3.0 umgestellt (`LICENSE` aus dem Hauptspiel, `package.json` → `GPL-3.0-only`). Der BSD-Text der Vorlage bleibt als `LICENSE-TEMPLATE.md`, denn BSD verlangt, dass der Hinweis erhalten bleibt. `CREDITS.md` angelegt. + +### 2026-11-13 – Phase 0: Engine und Assets + +- `scripts/sync-engine.sh ` holt per `git archive` (nur lesend) `src/core`, `src/campaign`, `src/puzzles` nach + `src/engine/` und Sprites (Tiles, Brett-Themen) sowie Musik nach `public/`. Stand in `src/engine/VERSION` + (`v0.11.0`). Avatare nicht übernommen (braucht die Basic-Version nicht). +- `src/engine/` hat eine eigene `tsconfig.json` (strict, `noUncheckedIndexedAccess` aus, `resolveJsonModule`, + `lib` WebWorker für `crypto`/`structuredClone`). **`erasableSyntaxOnly` aus**, weil `duel.ts` Parameter-Properties + nutzt. Biome ignoriert `src/engine/`. +- **Fallstrick:** Die Engine importiert ohne Dateiendung (`'./Board'`). `node --experimental-strip-types` kann das + nicht auflösen. Deshalb bündelt `test:unit` die Tests jetzt erst mit esbuild nach `dist/test/` (`--packages=external`, + damit `@devvit/web` als ein Modul geladen wird und die Redis-Attrappen im Server-Test greifen), dann `node --test`. + Der Glob `src/**/*.test.ts` läuft unter `sh` ohne globstar = nur eine Ebene tief (`src/*/…`). +- `src/shared/daily.ts`: Tageskarte mit Seed `fnv1a('ninelands-reddit-daily-' + UTC-Tag)`, Nummer ab 2026-11-13 (#1), + Welt-Regel wie im Hauptspiel `rolledTrait(seed)`. **Präfix und `FIRST_DAY` nie ändern** (sonst andere Karten und + Nummern rückwirkend); Schutztests in `daily.test.ts`. +- Ziele (`computeGoals`, 7 Bot-Partien) plus Nachspielen dauern in Node ≈ 80 ms – unproblematisch für den Server. + Für die Prüfung **nicht** `verifyProgress` aus `progress.ts` nehmen: das zieht Kampagnen-JSONs ins Bundle und kennt + nur die Website-Tageskarte. Eigene Prüfung aus `applyMoves` + `computeGoals` + `starsOf`. +- Musik: vorerst ein Titel („Peaceful Village“, MP3, 1,6 MB) – MP3 läuft auch in iOS-Webviews. Ein Größenlimit für + Webview-Assets steht nicht in der Doku; weitere Titel in `scripts/sync-engine.sh` (`musik=(…)`) ergänzen. diff --git a/biome.jsonc b/biome.jsonc index 1571ff2..fda6dc9 100644 --- a/biome.jsonc +++ b/biome.jsonc @@ -1,7 +1,8 @@ { "$schema": "https://biomejs.dev/schemas/2.6.6/schema.json", "css": {"formatter": {"enabled": true, "quoteStyle": "single"}}, - "files": {"includes": ["**", "!dist/", "!public/*.js*"]}, + // src/engine/ ist eine Kopie aus Ninelands (scripts/sync-engine.sh) und wird nicht umformatiert + "files": {"includes": ["**", "!dist/", "!public/*.js*", "!src/engine"]}, "formatter": { "bracketSpacing": false, "enabled": true, diff --git a/package.json b/package.json index 25297fd..4c82f9a 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "playtest": "devvit playtest", "test": "npm run test:types && npm run lint && npm run test:unit && npm run build", "test:types": "tsc --build", - "test:unit": "node --experimental-strip-types --no-warnings=ExperimentalWarning --test src/**/*.test.ts", + "test:unit": "esbuild --bundle --log-level=warning --platform=node --format=esm --packages=external --outdir=dist/test --outbase=src src/**/*.test.ts && node --test dist/test/**/*.test.js", "publish": "npm run clean && npm run build && devvit publish", "watch": "sh -c 'trap \"kill 0\" exit; npm run build:client -- --watch=forever& npm run build:server -- --watch=forever& wait' --" }, diff --git a/public/audio/music/peaceful-village.mp3 b/public/audio/music/peaceful-village.mp3 new file mode 100644 index 0000000..a87ad24 Binary files /dev/null and b/public/audio/music/peaceful-village.mp3 differ diff --git a/public/sprites/themes/autumn/cave.png b/public/sprites/themes/autumn/cave.png new file mode 100644 index 0000000..48e819e Binary files /dev/null and b/public/sprites/themes/autumn/cave.png differ diff --git a/public/sprites/themes/autumn/chapel.png b/public/sprites/themes/autumn/chapel.png new file mode 100644 index 0000000..b0e064b Binary files /dev/null and b/public/sprites/themes/autumn/chapel.png differ diff --git a/public/sprites/themes/autumn/grass0.png b/public/sprites/themes/autumn/grass0.png new file mode 100644 index 0000000..b0eb6a5 Binary files /dev/null and b/public/sprites/themes/autumn/grass0.png differ diff --git a/public/sprites/themes/autumn/grass1.png b/public/sprites/themes/autumn/grass1.png new file mode 100644 index 0000000..24ada9c Binary files /dev/null and b/public/sprites/themes/autumn/grass1.png differ diff --git a/public/sprites/themes/autumn/grass2.png b/public/sprites/themes/autumn/grass2.png new file mode 100644 index 0000000..304ff76 Binary files /dev/null and b/public/sprites/themes/autumn/grass2.png differ diff --git a/public/sprites/themes/autumn/grass3.png b/public/sprites/themes/autumn/grass3.png new file mode 100644 index 0000000..93fa7e8 Binary files /dev/null and b/public/sprites/themes/autumn/grass3.png differ diff --git a/public/sprites/themes/autumn/grass4.png b/public/sprites/themes/autumn/grass4.png new file mode 100644 index 0000000..a5aacb1 Binary files /dev/null and b/public/sprites/themes/autumn/grass4.png differ diff --git a/public/sprites/themes/autumn/grass5.png b/public/sprites/themes/autumn/grass5.png new file mode 100644 index 0000000..9e205d7 Binary files /dev/null and b/public/sprites/themes/autumn/grass5.png differ diff --git a/public/sprites/themes/autumn/house.png b/public/sprites/themes/autumn/house.png new file mode 100644 index 0000000..f99f216 Binary files /dev/null and b/public/sprites/themes/autumn/house.png differ diff --git a/public/sprites/themes/autumn/hut.png b/public/sprites/themes/autumn/hut.png new file mode 100644 index 0000000..f18aa52 Binary files /dev/null and b/public/sprites/themes/autumn/hut.png differ diff --git a/public/sprites/themes/autumn/moor0.png b/public/sprites/themes/autumn/moor0.png new file mode 100644 index 0000000..03b229f Binary files /dev/null and b/public/sprites/themes/autumn/moor0.png differ diff --git a/public/sprites/themes/autumn/moor1.png b/public/sprites/themes/autumn/moor1.png new file mode 100644 index 0000000..4e24358 Binary files /dev/null and b/public/sprites/themes/autumn/moor1.png differ diff --git a/public/sprites/themes/autumn/moor2.png b/public/sprites/themes/autumn/moor2.png new file mode 100644 index 0000000..f01e787 Binary files /dev/null and b/public/sprites/themes/autumn/moor2.png differ diff --git a/public/sprites/themes/autumn/moor3.png b/public/sprites/themes/autumn/moor3.png new file mode 100644 index 0000000..8a8f020 Binary files /dev/null and b/public/sprites/themes/autumn/moor3.png differ diff --git a/public/sprites/themes/autumn/moor4.png b/public/sprites/themes/autumn/moor4.png new file mode 100644 index 0000000..7f962c0 Binary files /dev/null and b/public/sprites/themes/autumn/moor4.png differ diff --git a/public/sprites/themes/autumn/moor5.png b/public/sprites/themes/autumn/moor5.png new file mode 100644 index 0000000..6330349 Binary files /dev/null and b/public/sprites/themes/autumn/moor5.png differ diff --git a/public/sprites/themes/autumn/rock0.png b/public/sprites/themes/autumn/rock0.png new file mode 100644 index 0000000..dbb51f4 Binary files /dev/null and b/public/sprites/themes/autumn/rock0.png differ diff --git a/public/sprites/themes/autumn/rock1.png b/public/sprites/themes/autumn/rock1.png new file mode 100644 index 0000000..4449628 Binary files /dev/null and b/public/sprites/themes/autumn/rock1.png differ diff --git a/public/sprites/themes/autumn/rock2.png b/public/sprites/themes/autumn/rock2.png new file mode 100644 index 0000000..5aa2321 Binary files /dev/null and b/public/sprites/themes/autumn/rock2.png differ diff --git a/public/sprites/themes/autumn/tree.png b/public/sprites/themes/autumn/tree.png new file mode 100644 index 0000000..0735068 Binary files /dev/null and b/public/sprites/themes/autumn/tree.png differ diff --git a/public/sprites/themes/autumn/water0.png b/public/sprites/themes/autumn/water0.png new file mode 100644 index 0000000..878f699 Binary files /dev/null and b/public/sprites/themes/autumn/water0.png differ diff --git a/public/sprites/themes/autumn/water1.png b/public/sprites/themes/autumn/water1.png new file mode 100644 index 0000000..51c1dc1 Binary files /dev/null and b/public/sprites/themes/autumn/water1.png differ diff --git a/public/sprites/themes/autumn/well.png b/public/sprites/themes/autumn/well.png new file mode 100644 index 0000000..9887907 Binary files /dev/null and b/public/sprites/themes/autumn/well.png differ diff --git a/public/sprites/themes/classic/cave.png b/public/sprites/themes/classic/cave.png new file mode 100644 index 0000000..473a02f Binary files /dev/null and b/public/sprites/themes/classic/cave.png differ diff --git a/public/sprites/themes/classic/chapel.png b/public/sprites/themes/classic/chapel.png new file mode 100644 index 0000000..b0e064b Binary files /dev/null and b/public/sprites/themes/classic/chapel.png differ diff --git a/public/sprites/themes/classic/grass0.png b/public/sprites/themes/classic/grass0.png new file mode 100644 index 0000000..23039b8 Binary files /dev/null and b/public/sprites/themes/classic/grass0.png differ diff --git a/public/sprites/themes/classic/grass1.png b/public/sprites/themes/classic/grass1.png new file mode 100644 index 0000000..764706e Binary files /dev/null and b/public/sprites/themes/classic/grass1.png differ diff --git a/public/sprites/themes/classic/grass2.png b/public/sprites/themes/classic/grass2.png new file mode 100644 index 0000000..495b707 Binary files /dev/null and b/public/sprites/themes/classic/grass2.png differ diff --git a/public/sprites/themes/classic/grass3.png b/public/sprites/themes/classic/grass3.png new file mode 100644 index 0000000..b5164da Binary files /dev/null and b/public/sprites/themes/classic/grass3.png differ diff --git a/public/sprites/themes/classic/grass4.png b/public/sprites/themes/classic/grass4.png new file mode 100644 index 0000000..be3565b Binary files /dev/null and b/public/sprites/themes/classic/grass4.png differ diff --git a/public/sprites/themes/classic/grass5.png b/public/sprites/themes/classic/grass5.png new file mode 100644 index 0000000..d075e7d Binary files /dev/null and b/public/sprites/themes/classic/grass5.png differ diff --git a/public/sprites/themes/classic/house.png b/public/sprites/themes/classic/house.png new file mode 100644 index 0000000..f99f216 Binary files /dev/null and b/public/sprites/themes/classic/house.png differ diff --git a/public/sprites/themes/classic/hut.png b/public/sprites/themes/classic/hut.png new file mode 100644 index 0000000..f18aa52 Binary files /dev/null and b/public/sprites/themes/classic/hut.png differ diff --git a/public/sprites/themes/classic/moor0.png b/public/sprites/themes/classic/moor0.png new file mode 100644 index 0000000..8127523 Binary files /dev/null and b/public/sprites/themes/classic/moor0.png differ diff --git a/public/sprites/themes/classic/moor1.png b/public/sprites/themes/classic/moor1.png new file mode 100644 index 0000000..05229bc Binary files /dev/null and b/public/sprites/themes/classic/moor1.png differ diff --git a/public/sprites/themes/classic/moor2.png b/public/sprites/themes/classic/moor2.png new file mode 100644 index 0000000..ded70fd Binary files /dev/null and b/public/sprites/themes/classic/moor2.png differ diff --git a/public/sprites/themes/classic/moor3.png b/public/sprites/themes/classic/moor3.png new file mode 100644 index 0000000..be7a3ec Binary files /dev/null and b/public/sprites/themes/classic/moor3.png differ diff --git a/public/sprites/themes/classic/moor4.png b/public/sprites/themes/classic/moor4.png new file mode 100644 index 0000000..9d1e599 Binary files /dev/null and b/public/sprites/themes/classic/moor4.png differ diff --git a/public/sprites/themes/classic/moor5.png b/public/sprites/themes/classic/moor5.png new file mode 100644 index 0000000..fdb5226 Binary files /dev/null and b/public/sprites/themes/classic/moor5.png differ diff --git a/public/sprites/themes/classic/rock0.png b/public/sprites/themes/classic/rock0.png new file mode 100644 index 0000000..dbb51f4 Binary files /dev/null and b/public/sprites/themes/classic/rock0.png differ diff --git a/public/sprites/themes/classic/rock1.png b/public/sprites/themes/classic/rock1.png new file mode 100644 index 0000000..4449628 Binary files /dev/null and b/public/sprites/themes/classic/rock1.png differ diff --git a/public/sprites/themes/classic/rock2.png b/public/sprites/themes/classic/rock2.png new file mode 100644 index 0000000..5aa2321 Binary files /dev/null and b/public/sprites/themes/classic/rock2.png differ diff --git a/public/sprites/themes/classic/tree.png b/public/sprites/themes/classic/tree.png new file mode 100644 index 0000000..346c833 Binary files /dev/null and b/public/sprites/themes/classic/tree.png differ diff --git a/public/sprites/themes/classic/water0.png b/public/sprites/themes/classic/water0.png new file mode 100644 index 0000000..878f699 Binary files /dev/null and b/public/sprites/themes/classic/water0.png differ diff --git a/public/sprites/themes/classic/water1.png b/public/sprites/themes/classic/water1.png new file mode 100644 index 0000000..51c1dc1 Binary files /dev/null and b/public/sprites/themes/classic/water1.png differ diff --git a/public/sprites/themes/classic/well.png b/public/sprites/themes/classic/well.png new file mode 100644 index 0000000..9887907 Binary files /dev/null and b/public/sprites/themes/classic/well.png differ diff --git a/public/sprites/themes/desert/cave.png b/public/sprites/themes/desert/cave.png new file mode 100644 index 0000000..69a09e7 Binary files /dev/null and b/public/sprites/themes/desert/cave.png differ diff --git a/public/sprites/themes/desert/chapel.png b/public/sprites/themes/desert/chapel.png new file mode 100644 index 0000000..177f5e6 Binary files /dev/null and b/public/sprites/themes/desert/chapel.png differ diff --git a/public/sprites/themes/desert/grass0.png b/public/sprites/themes/desert/grass0.png new file mode 100644 index 0000000..c58824e Binary files /dev/null and b/public/sprites/themes/desert/grass0.png differ diff --git a/public/sprites/themes/desert/grass1.png b/public/sprites/themes/desert/grass1.png new file mode 100644 index 0000000..2d01d56 Binary files /dev/null and b/public/sprites/themes/desert/grass1.png differ diff --git a/public/sprites/themes/desert/grass2.png b/public/sprites/themes/desert/grass2.png new file mode 100644 index 0000000..3c0e49b Binary files /dev/null and b/public/sprites/themes/desert/grass2.png differ diff --git a/public/sprites/themes/desert/grass3.png b/public/sprites/themes/desert/grass3.png new file mode 100644 index 0000000..16f7161 Binary files /dev/null and b/public/sprites/themes/desert/grass3.png differ diff --git a/public/sprites/themes/desert/grass4.png b/public/sprites/themes/desert/grass4.png new file mode 100644 index 0000000..6c131c0 Binary files /dev/null and b/public/sprites/themes/desert/grass4.png differ diff --git a/public/sprites/themes/desert/grass5.png b/public/sprites/themes/desert/grass5.png new file mode 100644 index 0000000..f3499e7 Binary files /dev/null and b/public/sprites/themes/desert/grass5.png differ diff --git a/public/sprites/themes/desert/house.png b/public/sprites/themes/desert/house.png new file mode 100644 index 0000000..d2198c1 Binary files /dev/null and b/public/sprites/themes/desert/house.png differ diff --git a/public/sprites/themes/desert/hut.png b/public/sprites/themes/desert/hut.png new file mode 100644 index 0000000..bc4174e Binary files /dev/null and b/public/sprites/themes/desert/hut.png differ diff --git a/public/sprites/themes/desert/moor0.png b/public/sprites/themes/desert/moor0.png new file mode 100644 index 0000000..9a4c081 Binary files /dev/null and b/public/sprites/themes/desert/moor0.png differ diff --git a/public/sprites/themes/desert/moor1.png b/public/sprites/themes/desert/moor1.png new file mode 100644 index 0000000..aca8af1 Binary files /dev/null and b/public/sprites/themes/desert/moor1.png differ diff --git a/public/sprites/themes/desert/moor2.png b/public/sprites/themes/desert/moor2.png new file mode 100644 index 0000000..d34ca31 Binary files /dev/null and b/public/sprites/themes/desert/moor2.png differ diff --git a/public/sprites/themes/desert/moor3.png b/public/sprites/themes/desert/moor3.png new file mode 100644 index 0000000..3b9385a Binary files /dev/null and b/public/sprites/themes/desert/moor3.png differ diff --git a/public/sprites/themes/desert/moor4.png b/public/sprites/themes/desert/moor4.png new file mode 100644 index 0000000..5eeb63e Binary files /dev/null and b/public/sprites/themes/desert/moor4.png differ diff --git a/public/sprites/themes/desert/moor5.png b/public/sprites/themes/desert/moor5.png new file mode 100644 index 0000000..940ce58 Binary files /dev/null and b/public/sprites/themes/desert/moor5.png differ diff --git a/public/sprites/themes/desert/rock0.png b/public/sprites/themes/desert/rock0.png new file mode 100644 index 0000000..12c51ae Binary files /dev/null and b/public/sprites/themes/desert/rock0.png differ diff --git a/public/sprites/themes/desert/rock1.png b/public/sprites/themes/desert/rock1.png new file mode 100644 index 0000000..8009d27 Binary files /dev/null and b/public/sprites/themes/desert/rock1.png differ diff --git a/public/sprites/themes/desert/rock2.png b/public/sprites/themes/desert/rock2.png new file mode 100644 index 0000000..20f7a11 Binary files /dev/null and b/public/sprites/themes/desert/rock2.png differ diff --git a/public/sprites/themes/desert/tree.png b/public/sprites/themes/desert/tree.png new file mode 100644 index 0000000..1cfb6ba Binary files /dev/null and b/public/sprites/themes/desert/tree.png differ diff --git a/public/sprites/themes/desert/water0.png b/public/sprites/themes/desert/water0.png new file mode 100644 index 0000000..878f699 Binary files /dev/null and b/public/sprites/themes/desert/water0.png differ diff --git a/public/sprites/themes/desert/water1.png b/public/sprites/themes/desert/water1.png new file mode 100644 index 0000000..51c1dc1 Binary files /dev/null and b/public/sprites/themes/desert/water1.png differ diff --git a/public/sprites/themes/desert/well.png b/public/sprites/themes/desert/well.png new file mode 100644 index 0000000..c95eb2c Binary files /dev/null and b/public/sprites/themes/desert/well.png differ diff --git a/public/sprites/themes/easter/cave.png b/public/sprites/themes/easter/cave.png new file mode 100644 index 0000000..473a02f Binary files /dev/null and b/public/sprites/themes/easter/cave.png differ diff --git a/public/sprites/themes/easter/chapel.png b/public/sprites/themes/easter/chapel.png new file mode 100644 index 0000000..b0e064b Binary files /dev/null and b/public/sprites/themes/easter/chapel.png differ diff --git a/public/sprites/themes/easter/grass0.png b/public/sprites/themes/easter/grass0.png new file mode 100644 index 0000000..b1f03f8 Binary files /dev/null and b/public/sprites/themes/easter/grass0.png differ diff --git a/public/sprites/themes/easter/grass1.png b/public/sprites/themes/easter/grass1.png new file mode 100644 index 0000000..5e69fc1 Binary files /dev/null and b/public/sprites/themes/easter/grass1.png differ diff --git a/public/sprites/themes/easter/grass2.png b/public/sprites/themes/easter/grass2.png new file mode 100644 index 0000000..40c1700 Binary files /dev/null and b/public/sprites/themes/easter/grass2.png differ diff --git a/public/sprites/themes/easter/grass3.png b/public/sprites/themes/easter/grass3.png new file mode 100644 index 0000000..e00bb73 Binary files /dev/null and b/public/sprites/themes/easter/grass3.png differ diff --git a/public/sprites/themes/easter/grass4.png b/public/sprites/themes/easter/grass4.png new file mode 100644 index 0000000..eea28d0 Binary files /dev/null and b/public/sprites/themes/easter/grass4.png differ diff --git a/public/sprites/themes/easter/grass5.png b/public/sprites/themes/easter/grass5.png new file mode 100644 index 0000000..9b15364 Binary files /dev/null and b/public/sprites/themes/easter/grass5.png differ diff --git a/public/sprites/themes/easter/house.png b/public/sprites/themes/easter/house.png new file mode 100644 index 0000000..f99f216 Binary files /dev/null and b/public/sprites/themes/easter/house.png differ diff --git a/public/sprites/themes/easter/hut.png b/public/sprites/themes/easter/hut.png new file mode 100644 index 0000000..f18aa52 Binary files /dev/null and b/public/sprites/themes/easter/hut.png differ diff --git a/public/sprites/themes/easter/moor0.png b/public/sprites/themes/easter/moor0.png new file mode 100644 index 0000000..62e8878 Binary files /dev/null and b/public/sprites/themes/easter/moor0.png differ diff --git a/public/sprites/themes/easter/moor1.png b/public/sprites/themes/easter/moor1.png new file mode 100644 index 0000000..319e416 Binary files /dev/null and b/public/sprites/themes/easter/moor1.png differ diff --git a/public/sprites/themes/easter/moor2.png b/public/sprites/themes/easter/moor2.png new file mode 100644 index 0000000..b0a1c1a Binary files /dev/null and b/public/sprites/themes/easter/moor2.png differ diff --git a/public/sprites/themes/easter/moor3.png b/public/sprites/themes/easter/moor3.png new file mode 100644 index 0000000..8070486 Binary files /dev/null and b/public/sprites/themes/easter/moor3.png differ diff --git a/public/sprites/themes/easter/moor4.png b/public/sprites/themes/easter/moor4.png new file mode 100644 index 0000000..19e188c Binary files /dev/null and b/public/sprites/themes/easter/moor4.png differ diff --git a/public/sprites/themes/easter/moor5.png b/public/sprites/themes/easter/moor5.png new file mode 100644 index 0000000..33c60cf Binary files /dev/null and b/public/sprites/themes/easter/moor5.png differ diff --git a/public/sprites/themes/easter/rock0.png b/public/sprites/themes/easter/rock0.png new file mode 100644 index 0000000..dbb51f4 Binary files /dev/null and b/public/sprites/themes/easter/rock0.png differ diff --git a/public/sprites/themes/easter/rock1.png b/public/sprites/themes/easter/rock1.png new file mode 100644 index 0000000..4449628 Binary files /dev/null and b/public/sprites/themes/easter/rock1.png differ diff --git a/public/sprites/themes/easter/rock2.png b/public/sprites/themes/easter/rock2.png new file mode 100644 index 0000000..5aa2321 Binary files /dev/null and b/public/sprites/themes/easter/rock2.png differ diff --git a/public/sprites/themes/easter/tree.png b/public/sprites/themes/easter/tree.png new file mode 100644 index 0000000..c8c0a81 Binary files /dev/null and b/public/sprites/themes/easter/tree.png differ diff --git a/public/sprites/themes/easter/water0.png b/public/sprites/themes/easter/water0.png new file mode 100644 index 0000000..c3db68d Binary files /dev/null and b/public/sprites/themes/easter/water0.png differ diff --git a/public/sprites/themes/easter/water1.png b/public/sprites/themes/easter/water1.png new file mode 100644 index 0000000..bb3e10f Binary files /dev/null and b/public/sprites/themes/easter/water1.png differ diff --git a/public/sprites/themes/easter/well.png b/public/sprites/themes/easter/well.png new file mode 100644 index 0000000..9887907 Binary files /dev/null and b/public/sprites/themes/easter/well.png differ diff --git a/public/sprites/themes/halloween/cave.png b/public/sprites/themes/halloween/cave.png new file mode 100644 index 0000000..212128c Binary files /dev/null and b/public/sprites/themes/halloween/cave.png differ diff --git a/public/sprites/themes/halloween/chapel.png b/public/sprites/themes/halloween/chapel.png new file mode 100644 index 0000000..d945ece Binary files /dev/null and b/public/sprites/themes/halloween/chapel.png differ diff --git a/public/sprites/themes/halloween/grass0.png b/public/sprites/themes/halloween/grass0.png new file mode 100644 index 0000000..ceba3e7 Binary files /dev/null and b/public/sprites/themes/halloween/grass0.png differ diff --git a/public/sprites/themes/halloween/grass1.png b/public/sprites/themes/halloween/grass1.png new file mode 100644 index 0000000..b09fb16 Binary files /dev/null and b/public/sprites/themes/halloween/grass1.png differ diff --git a/public/sprites/themes/halloween/grass2.png b/public/sprites/themes/halloween/grass2.png new file mode 100644 index 0000000..192728e Binary files /dev/null and b/public/sprites/themes/halloween/grass2.png differ diff --git a/public/sprites/themes/halloween/grass3.png b/public/sprites/themes/halloween/grass3.png new file mode 100644 index 0000000..538bff4 Binary files /dev/null and b/public/sprites/themes/halloween/grass3.png differ diff --git a/public/sprites/themes/halloween/grass4.png b/public/sprites/themes/halloween/grass4.png new file mode 100644 index 0000000..7ec1e2b Binary files /dev/null and b/public/sprites/themes/halloween/grass4.png differ diff --git a/public/sprites/themes/halloween/grass5.png b/public/sprites/themes/halloween/grass5.png new file mode 100644 index 0000000..4033f3a Binary files /dev/null and b/public/sprites/themes/halloween/grass5.png differ diff --git a/public/sprites/themes/halloween/house.png b/public/sprites/themes/halloween/house.png new file mode 100644 index 0000000..af73c79 Binary files /dev/null and b/public/sprites/themes/halloween/house.png differ diff --git a/public/sprites/themes/halloween/hut.png b/public/sprites/themes/halloween/hut.png new file mode 100644 index 0000000..b8aebaf Binary files /dev/null and b/public/sprites/themes/halloween/hut.png differ diff --git a/public/sprites/themes/halloween/moor0.png b/public/sprites/themes/halloween/moor0.png new file mode 100644 index 0000000..0183fde Binary files /dev/null and b/public/sprites/themes/halloween/moor0.png differ diff --git a/public/sprites/themes/halloween/moor1.png b/public/sprites/themes/halloween/moor1.png new file mode 100644 index 0000000..3e93c28 Binary files /dev/null and b/public/sprites/themes/halloween/moor1.png differ diff --git a/public/sprites/themes/halloween/moor2.png b/public/sprites/themes/halloween/moor2.png new file mode 100644 index 0000000..22e4985 Binary files /dev/null and b/public/sprites/themes/halloween/moor2.png differ diff --git a/public/sprites/themes/halloween/moor3.png b/public/sprites/themes/halloween/moor3.png new file mode 100644 index 0000000..836fdfe Binary files /dev/null and b/public/sprites/themes/halloween/moor3.png differ diff --git a/public/sprites/themes/halloween/moor4.png b/public/sprites/themes/halloween/moor4.png new file mode 100644 index 0000000..1f0b3a4 Binary files /dev/null and b/public/sprites/themes/halloween/moor4.png differ diff --git a/public/sprites/themes/halloween/moor5.png b/public/sprites/themes/halloween/moor5.png new file mode 100644 index 0000000..a4925f8 Binary files /dev/null and b/public/sprites/themes/halloween/moor5.png differ diff --git a/public/sprites/themes/halloween/rock0.png b/public/sprites/themes/halloween/rock0.png new file mode 100644 index 0000000..e534e76 Binary files /dev/null and b/public/sprites/themes/halloween/rock0.png differ diff --git a/public/sprites/themes/halloween/rock1.png b/public/sprites/themes/halloween/rock1.png new file mode 100644 index 0000000..00529fb Binary files /dev/null and b/public/sprites/themes/halloween/rock1.png differ diff --git a/public/sprites/themes/halloween/rock2.png b/public/sprites/themes/halloween/rock2.png new file mode 100644 index 0000000..182961b Binary files /dev/null and b/public/sprites/themes/halloween/rock2.png differ diff --git a/public/sprites/themes/halloween/tree.png b/public/sprites/themes/halloween/tree.png new file mode 100644 index 0000000..2597817 Binary files /dev/null and b/public/sprites/themes/halloween/tree.png differ diff --git a/public/sprites/themes/halloween/water0.png b/public/sprites/themes/halloween/water0.png new file mode 100644 index 0000000..33abff6 Binary files /dev/null and b/public/sprites/themes/halloween/water0.png differ diff --git a/public/sprites/themes/halloween/water1.png b/public/sprites/themes/halloween/water1.png new file mode 100644 index 0000000..80570aa Binary files /dev/null and b/public/sprites/themes/halloween/water1.png differ diff --git a/public/sprites/themes/halloween/well.png b/public/sprites/themes/halloween/well.png new file mode 100644 index 0000000..a8b9af1 Binary files /dev/null and b/public/sprites/themes/halloween/well.png differ diff --git a/public/sprites/themes/night/cave.png b/public/sprites/themes/night/cave.png new file mode 100644 index 0000000..cfcd0be Binary files /dev/null and b/public/sprites/themes/night/cave.png differ diff --git a/public/sprites/themes/night/chapel.png b/public/sprites/themes/night/chapel.png new file mode 100644 index 0000000..9d615a0 Binary files /dev/null and b/public/sprites/themes/night/chapel.png differ diff --git a/public/sprites/themes/night/grass0.png b/public/sprites/themes/night/grass0.png new file mode 100644 index 0000000..9758835 Binary files /dev/null and b/public/sprites/themes/night/grass0.png differ diff --git a/public/sprites/themes/night/grass1.png b/public/sprites/themes/night/grass1.png new file mode 100644 index 0000000..9290917 Binary files /dev/null and b/public/sprites/themes/night/grass1.png differ diff --git a/public/sprites/themes/night/grass2.png b/public/sprites/themes/night/grass2.png new file mode 100644 index 0000000..2f649f5 Binary files /dev/null and b/public/sprites/themes/night/grass2.png differ diff --git a/public/sprites/themes/night/grass3.png b/public/sprites/themes/night/grass3.png new file mode 100644 index 0000000..5f945c5 Binary files /dev/null and b/public/sprites/themes/night/grass3.png differ diff --git a/public/sprites/themes/night/grass4.png b/public/sprites/themes/night/grass4.png new file mode 100644 index 0000000..e1b46ad Binary files /dev/null and b/public/sprites/themes/night/grass4.png differ diff --git a/public/sprites/themes/night/grass5.png b/public/sprites/themes/night/grass5.png new file mode 100644 index 0000000..492b6f4 Binary files /dev/null and b/public/sprites/themes/night/grass5.png differ diff --git a/public/sprites/themes/night/house.png b/public/sprites/themes/night/house.png new file mode 100644 index 0000000..e9574c9 Binary files /dev/null and b/public/sprites/themes/night/house.png differ diff --git a/public/sprites/themes/night/hut.png b/public/sprites/themes/night/hut.png new file mode 100644 index 0000000..2d0be0b Binary files /dev/null and b/public/sprites/themes/night/hut.png differ diff --git a/public/sprites/themes/night/moor0.png b/public/sprites/themes/night/moor0.png new file mode 100644 index 0000000..620febb Binary files /dev/null and b/public/sprites/themes/night/moor0.png differ diff --git a/public/sprites/themes/night/moor1.png b/public/sprites/themes/night/moor1.png new file mode 100644 index 0000000..919b1bc Binary files /dev/null and b/public/sprites/themes/night/moor1.png differ diff --git a/public/sprites/themes/night/moor2.png b/public/sprites/themes/night/moor2.png new file mode 100644 index 0000000..72f2377 Binary files /dev/null and b/public/sprites/themes/night/moor2.png differ diff --git a/public/sprites/themes/night/moor3.png b/public/sprites/themes/night/moor3.png new file mode 100644 index 0000000..dd6b68c Binary files /dev/null and b/public/sprites/themes/night/moor3.png differ diff --git a/public/sprites/themes/night/moor4.png b/public/sprites/themes/night/moor4.png new file mode 100644 index 0000000..93591c9 Binary files /dev/null and b/public/sprites/themes/night/moor4.png differ diff --git a/public/sprites/themes/night/moor5.png b/public/sprites/themes/night/moor5.png new file mode 100644 index 0000000..e1c2521 Binary files /dev/null and b/public/sprites/themes/night/moor5.png differ diff --git a/public/sprites/themes/night/rock0.png b/public/sprites/themes/night/rock0.png new file mode 100644 index 0000000..53f2a0e Binary files /dev/null and b/public/sprites/themes/night/rock0.png differ diff --git a/public/sprites/themes/night/rock1.png b/public/sprites/themes/night/rock1.png new file mode 100644 index 0000000..8d8e914 Binary files /dev/null and b/public/sprites/themes/night/rock1.png differ diff --git a/public/sprites/themes/night/rock2.png b/public/sprites/themes/night/rock2.png new file mode 100644 index 0000000..970e05e Binary files /dev/null and b/public/sprites/themes/night/rock2.png differ diff --git a/public/sprites/themes/night/tree.png b/public/sprites/themes/night/tree.png new file mode 100644 index 0000000..8c9cd52 Binary files /dev/null and b/public/sprites/themes/night/tree.png differ diff --git a/public/sprites/themes/night/water0.png b/public/sprites/themes/night/water0.png new file mode 100644 index 0000000..d56f475 Binary files /dev/null and b/public/sprites/themes/night/water0.png differ diff --git a/public/sprites/themes/night/water1.png b/public/sprites/themes/night/water1.png new file mode 100644 index 0000000..42901ea Binary files /dev/null and b/public/sprites/themes/night/water1.png differ diff --git a/public/sprites/themes/night/well.png b/public/sprites/themes/night/well.png new file mode 100644 index 0000000..4b57c0d Binary files /dev/null and b/public/sprites/themes/night/well.png differ diff --git a/public/sprites/themes/winter/cave.png b/public/sprites/themes/winter/cave.png new file mode 100644 index 0000000..c4cd13f Binary files /dev/null and b/public/sprites/themes/winter/cave.png differ diff --git a/public/sprites/themes/winter/chapel.png b/public/sprites/themes/winter/chapel.png new file mode 100644 index 0000000..e5d7a8a Binary files /dev/null and b/public/sprites/themes/winter/chapel.png differ diff --git a/public/sprites/themes/winter/grass0.png b/public/sprites/themes/winter/grass0.png new file mode 100644 index 0000000..c7411ac Binary files /dev/null and b/public/sprites/themes/winter/grass0.png differ diff --git a/public/sprites/themes/winter/grass1.png b/public/sprites/themes/winter/grass1.png new file mode 100644 index 0000000..8027d20 Binary files /dev/null and b/public/sprites/themes/winter/grass1.png differ diff --git a/public/sprites/themes/winter/grass2.png b/public/sprites/themes/winter/grass2.png new file mode 100644 index 0000000..9f4464a Binary files /dev/null and b/public/sprites/themes/winter/grass2.png differ diff --git a/public/sprites/themes/winter/grass3.png b/public/sprites/themes/winter/grass3.png new file mode 100644 index 0000000..853972f Binary files /dev/null and b/public/sprites/themes/winter/grass3.png differ diff --git a/public/sprites/themes/winter/grass4.png b/public/sprites/themes/winter/grass4.png new file mode 100644 index 0000000..b087f74 Binary files /dev/null and b/public/sprites/themes/winter/grass4.png differ diff --git a/public/sprites/themes/winter/grass5.png b/public/sprites/themes/winter/grass5.png new file mode 100644 index 0000000..b42efbb Binary files /dev/null and b/public/sprites/themes/winter/grass5.png differ diff --git a/public/sprites/themes/winter/house.png b/public/sprites/themes/winter/house.png new file mode 100644 index 0000000..0324809 Binary files /dev/null and b/public/sprites/themes/winter/house.png differ diff --git a/public/sprites/themes/winter/hut.png b/public/sprites/themes/winter/hut.png new file mode 100644 index 0000000..894f8b8 Binary files /dev/null and b/public/sprites/themes/winter/hut.png differ diff --git a/public/sprites/themes/winter/moor0.png b/public/sprites/themes/winter/moor0.png new file mode 100644 index 0000000..021add2 Binary files /dev/null and b/public/sprites/themes/winter/moor0.png differ diff --git a/public/sprites/themes/winter/moor1.png b/public/sprites/themes/winter/moor1.png new file mode 100644 index 0000000..d66d844 Binary files /dev/null and b/public/sprites/themes/winter/moor1.png differ diff --git a/public/sprites/themes/winter/moor2.png b/public/sprites/themes/winter/moor2.png new file mode 100644 index 0000000..f01c094 Binary files /dev/null and b/public/sprites/themes/winter/moor2.png differ diff --git a/public/sprites/themes/winter/moor3.png b/public/sprites/themes/winter/moor3.png new file mode 100644 index 0000000..b934259 Binary files /dev/null and b/public/sprites/themes/winter/moor3.png differ diff --git a/public/sprites/themes/winter/moor4.png b/public/sprites/themes/winter/moor4.png new file mode 100644 index 0000000..5763a2b Binary files /dev/null and b/public/sprites/themes/winter/moor4.png differ diff --git a/public/sprites/themes/winter/moor5.png b/public/sprites/themes/winter/moor5.png new file mode 100644 index 0000000..ea356d9 Binary files /dev/null and b/public/sprites/themes/winter/moor5.png differ diff --git a/public/sprites/themes/winter/rock0.png b/public/sprites/themes/winter/rock0.png new file mode 100644 index 0000000..9831ad9 Binary files /dev/null and b/public/sprites/themes/winter/rock0.png differ diff --git a/public/sprites/themes/winter/rock1.png b/public/sprites/themes/winter/rock1.png new file mode 100644 index 0000000..bda1949 Binary files /dev/null and b/public/sprites/themes/winter/rock1.png differ diff --git a/public/sprites/themes/winter/rock2.png b/public/sprites/themes/winter/rock2.png new file mode 100644 index 0000000..22999b2 Binary files /dev/null and b/public/sprites/themes/winter/rock2.png differ diff --git a/public/sprites/themes/winter/tree.png b/public/sprites/themes/winter/tree.png new file mode 100644 index 0000000..6a84164 Binary files /dev/null and b/public/sprites/themes/winter/tree.png differ diff --git a/public/sprites/themes/winter/water0.png b/public/sprites/themes/winter/water0.png new file mode 100644 index 0000000..cce2e80 Binary files /dev/null and b/public/sprites/themes/winter/water0.png differ diff --git a/public/sprites/themes/winter/water1.png b/public/sprites/themes/winter/water1.png new file mode 100644 index 0000000..abde49a Binary files /dev/null and b/public/sprites/themes/winter/water1.png differ diff --git a/public/sprites/themes/winter/well.png b/public/sprites/themes/winter/well.png new file mode 100644 index 0000000..95a29a4 Binary files /dev/null and b/public/sprites/themes/winter/well.png differ diff --git a/public/sprites/themes/winterfest/cave.png b/public/sprites/themes/winterfest/cave.png new file mode 100644 index 0000000..b507a0c Binary files /dev/null and b/public/sprites/themes/winterfest/cave.png differ diff --git a/public/sprites/themes/winterfest/chapel.png b/public/sprites/themes/winterfest/chapel.png new file mode 100644 index 0000000..d35766b Binary files /dev/null and b/public/sprites/themes/winterfest/chapel.png differ diff --git a/public/sprites/themes/winterfest/grass0.png b/public/sprites/themes/winterfest/grass0.png new file mode 100644 index 0000000..abdb329 Binary files /dev/null and b/public/sprites/themes/winterfest/grass0.png differ diff --git a/public/sprites/themes/winterfest/grass1.png b/public/sprites/themes/winterfest/grass1.png new file mode 100644 index 0000000..33f48f1 Binary files /dev/null and b/public/sprites/themes/winterfest/grass1.png differ diff --git a/public/sprites/themes/winterfest/grass2.png b/public/sprites/themes/winterfest/grass2.png new file mode 100644 index 0000000..86d293f Binary files /dev/null and b/public/sprites/themes/winterfest/grass2.png differ diff --git a/public/sprites/themes/winterfest/grass3.png b/public/sprites/themes/winterfest/grass3.png new file mode 100644 index 0000000..160586c Binary files /dev/null and b/public/sprites/themes/winterfest/grass3.png differ diff --git a/public/sprites/themes/winterfest/grass4.png b/public/sprites/themes/winterfest/grass4.png new file mode 100644 index 0000000..0ccf553 Binary files /dev/null and b/public/sprites/themes/winterfest/grass4.png differ diff --git a/public/sprites/themes/winterfest/grass5.png b/public/sprites/themes/winterfest/grass5.png new file mode 100644 index 0000000..cee7ae8 Binary files /dev/null and b/public/sprites/themes/winterfest/grass5.png differ diff --git a/public/sprites/themes/winterfest/house.png b/public/sprites/themes/winterfest/house.png new file mode 100644 index 0000000..feb7984 Binary files /dev/null and b/public/sprites/themes/winterfest/house.png differ diff --git a/public/sprites/themes/winterfest/hut.png b/public/sprites/themes/winterfest/hut.png new file mode 100644 index 0000000..e3b3956 Binary files /dev/null and b/public/sprites/themes/winterfest/hut.png differ diff --git a/public/sprites/themes/winterfest/moor0.png b/public/sprites/themes/winterfest/moor0.png new file mode 100644 index 0000000..ec20538 Binary files /dev/null and b/public/sprites/themes/winterfest/moor0.png differ diff --git a/public/sprites/themes/winterfest/moor1.png b/public/sprites/themes/winterfest/moor1.png new file mode 100644 index 0000000..afad515 Binary files /dev/null and b/public/sprites/themes/winterfest/moor1.png differ diff --git a/public/sprites/themes/winterfest/moor2.png b/public/sprites/themes/winterfest/moor2.png new file mode 100644 index 0000000..3070727 Binary files /dev/null and b/public/sprites/themes/winterfest/moor2.png differ diff --git a/public/sprites/themes/winterfest/moor3.png b/public/sprites/themes/winterfest/moor3.png new file mode 100644 index 0000000..e3c49d6 Binary files /dev/null and b/public/sprites/themes/winterfest/moor3.png differ diff --git a/public/sprites/themes/winterfest/moor4.png b/public/sprites/themes/winterfest/moor4.png new file mode 100644 index 0000000..4f016ad Binary files /dev/null and b/public/sprites/themes/winterfest/moor4.png differ diff --git a/public/sprites/themes/winterfest/moor5.png b/public/sprites/themes/winterfest/moor5.png new file mode 100644 index 0000000..e9e53c6 Binary files /dev/null and b/public/sprites/themes/winterfest/moor5.png differ diff --git a/public/sprites/themes/winterfest/rock0.png b/public/sprites/themes/winterfest/rock0.png new file mode 100644 index 0000000..737f4de Binary files /dev/null and b/public/sprites/themes/winterfest/rock0.png differ diff --git a/public/sprites/themes/winterfest/rock1.png b/public/sprites/themes/winterfest/rock1.png new file mode 100644 index 0000000..fb6c511 Binary files /dev/null and b/public/sprites/themes/winterfest/rock1.png differ diff --git a/public/sprites/themes/winterfest/rock2.png b/public/sprites/themes/winterfest/rock2.png new file mode 100644 index 0000000..49e26a1 Binary files /dev/null and b/public/sprites/themes/winterfest/rock2.png differ diff --git a/public/sprites/themes/winterfest/tree.png b/public/sprites/themes/winterfest/tree.png new file mode 100644 index 0000000..fe7b242 Binary files /dev/null and b/public/sprites/themes/winterfest/tree.png differ diff --git a/public/sprites/themes/winterfest/water0.png b/public/sprites/themes/winterfest/water0.png new file mode 100644 index 0000000..8419ec1 Binary files /dev/null and b/public/sprites/themes/winterfest/water0.png differ diff --git a/public/sprites/themes/winterfest/water1.png b/public/sprites/themes/winterfest/water1.png new file mode 100644 index 0000000..32e33da Binary files /dev/null and b/public/sprites/themes/winterfest/water1.png differ diff --git a/public/sprites/themes/winterfest/well.png b/public/sprites/themes/winterfest/well.png new file mode 100644 index 0000000..024a091 Binary files /dev/null and b/public/sprites/themes/winterfest/well.png differ diff --git a/public/sprites/tiles/bandit.png b/public/sprites/tiles/bandit.png new file mode 100644 index 0000000..510da90 Binary files /dev/null and b/public/sprites/tiles/bandit.png differ diff --git a/public/sprites/tiles/bishop-cyan.png b/public/sprites/tiles/bishop-cyan.png new file mode 100644 index 0000000..3c8da28 Binary files /dev/null and b/public/sprites/tiles/bishop-cyan.png differ diff --git a/public/sprites/tiles/bishop-lime.png b/public/sprites/tiles/bishop-lime.png new file mode 100644 index 0000000..dc40ddd Binary files /dev/null and b/public/sprites/tiles/bishop-lime.png differ diff --git a/public/sprites/tiles/bishop-purple.png b/public/sprites/tiles/bishop-purple.png new file mode 100644 index 0000000..b2bfc1b Binary files /dev/null and b/public/sprites/tiles/bishop-purple.png differ diff --git a/public/sprites/tiles/bishop-red.png b/public/sprites/tiles/bishop-red.png new file mode 100644 index 0000000..c208fb9 Binary files /dev/null and b/public/sprites/tiles/bishop-red.png differ diff --git a/public/sprites/tiles/boxGreen.png b/public/sprites/tiles/boxGreen.png new file mode 100644 index 0000000..3183c3e Binary files /dev/null and b/public/sprites/tiles/boxGreen.png differ diff --git a/public/sprites/tiles/boxRed.png b/public/sprites/tiles/boxRed.png new file mode 100644 index 0000000..7c3adf1 Binary files /dev/null and b/public/sprites/tiles/boxRed.png differ diff --git a/public/sprites/tiles/boxYellow.png b/public/sprites/tiles/boxYellow.png new file mode 100644 index 0000000..a7ff804 Binary files /dev/null and b/public/sprites/tiles/boxYellow.png differ diff --git a/public/sprites/tiles/camp.png b/public/sprites/tiles/camp.png new file mode 100644 index 0000000..79d3746 Binary files /dev/null and b/public/sprites/tiles/camp.png differ diff --git a/public/sprites/tiles/castle-cyan.png b/public/sprites/tiles/castle-cyan.png new file mode 100644 index 0000000..e8092ce Binary files /dev/null and b/public/sprites/tiles/castle-cyan.png differ diff --git a/public/sprites/tiles/castle-lime.png b/public/sprites/tiles/castle-lime.png new file mode 100644 index 0000000..c93b61d Binary files /dev/null and b/public/sprites/tiles/castle-lime.png differ diff --git a/public/sprites/tiles/castle-purple.png b/public/sprites/tiles/castle-purple.png new file mode 100644 index 0000000..a389ae0 Binary files /dev/null and b/public/sprites/tiles/castle-purple.png differ diff --git a/public/sprites/tiles/castle-red.png b/public/sprites/tiles/castle-red.png new file mode 100644 index 0000000..6670cc5 Binary files /dev/null and b/public/sprites/tiles/castle-red.png differ diff --git a/public/sprites/tiles/cave.png b/public/sprites/tiles/cave.png new file mode 100644 index 0000000..a8cb7df Binary files /dev/null and b/public/sprites/tiles/cave.png differ diff --git a/public/sprites/tiles/chapel.png b/public/sprites/tiles/chapel.png new file mode 100644 index 0000000..f15114f Binary files /dev/null and b/public/sprites/tiles/chapel.png differ diff --git a/public/sprites/tiles/coin.png b/public/sprites/tiles/coin.png new file mode 100644 index 0000000..0789f25 Binary files /dev/null and b/public/sprites/tiles/coin.png differ diff --git a/public/sprites/tiles/farmer-cyan.png b/public/sprites/tiles/farmer-cyan.png new file mode 100644 index 0000000..9c040af Binary files /dev/null and b/public/sprites/tiles/farmer-cyan.png differ diff --git a/public/sprites/tiles/farmer-lime.png b/public/sprites/tiles/farmer-lime.png new file mode 100644 index 0000000..bd613e5 Binary files /dev/null and b/public/sprites/tiles/farmer-lime.png differ diff --git a/public/sprites/tiles/farmer-purple.png b/public/sprites/tiles/farmer-purple.png new file mode 100644 index 0000000..7e17510 Binary files /dev/null and b/public/sprites/tiles/farmer-purple.png differ diff --git a/public/sprites/tiles/farmer-red.png b/public/sprites/tiles/farmer-red.png new file mode 100644 index 0000000..05bbfd8 Binary files /dev/null and b/public/sprites/tiles/farmer-red.png differ diff --git a/public/sprites/tiles/grass0.png b/public/sprites/tiles/grass0.png new file mode 100644 index 0000000..f7b1eb1 Binary files /dev/null and b/public/sprites/tiles/grass0.png differ diff --git a/public/sprites/tiles/grass1.png b/public/sprites/tiles/grass1.png new file mode 100644 index 0000000..8b9413b Binary files /dev/null and b/public/sprites/tiles/grass1.png differ diff --git a/public/sprites/tiles/grass2.png b/public/sprites/tiles/grass2.png new file mode 100644 index 0000000..828988d Binary files /dev/null and b/public/sprites/tiles/grass2.png differ diff --git a/public/sprites/tiles/grass3.png b/public/sprites/tiles/grass3.png new file mode 100644 index 0000000..45d3555 Binary files /dev/null and b/public/sprites/tiles/grass3.png differ diff --git a/public/sprites/tiles/grass4.png b/public/sprites/tiles/grass4.png new file mode 100644 index 0000000..f5cbb17 Binary files /dev/null and b/public/sprites/tiles/grass4.png differ diff --git a/public/sprites/tiles/grass5.png b/public/sprites/tiles/grass5.png new file mode 100644 index 0000000..5314d97 Binary files /dev/null and b/public/sprites/tiles/grass5.png differ diff --git a/public/sprites/tiles/herbs.png b/public/sprites/tiles/herbs.png new file mode 100644 index 0000000..dfde2b0 Binary files /dev/null and b/public/sprites/tiles/herbs.png differ diff --git a/public/sprites/tiles/house.png b/public/sprites/tiles/house.png new file mode 100644 index 0000000..9c13024 Binary files /dev/null and b/public/sprites/tiles/house.png differ diff --git a/public/sprites/tiles/hut.png b/public/sprites/tiles/hut.png new file mode 100644 index 0000000..735f7ff Binary files /dev/null and b/public/sprites/tiles/hut.png differ diff --git a/public/sprites/tiles/knight-cyan.png b/public/sprites/tiles/knight-cyan.png new file mode 100644 index 0000000..a9cea7d Binary files /dev/null and b/public/sprites/tiles/knight-cyan.png differ diff --git a/public/sprites/tiles/knight-lime.png b/public/sprites/tiles/knight-lime.png new file mode 100644 index 0000000..4a29b0d Binary files /dev/null and b/public/sprites/tiles/knight-lime.png differ diff --git a/public/sprites/tiles/knight-purple.png b/public/sprites/tiles/knight-purple.png new file mode 100644 index 0000000..27cfc04 Binary files /dev/null and b/public/sprites/tiles/knight-purple.png differ diff --git a/public/sprites/tiles/knight-red.png b/public/sprites/tiles/knight-red.png new file mode 100644 index 0000000..c5ba4e2 Binary files /dev/null and b/public/sprites/tiles/knight-red.png differ diff --git a/public/sprites/tiles/moor0.png b/public/sprites/tiles/moor0.png new file mode 100644 index 0000000..0e2b3d5 Binary files /dev/null and b/public/sprites/tiles/moor0.png differ diff --git a/public/sprites/tiles/moor1.png b/public/sprites/tiles/moor1.png new file mode 100644 index 0000000..436b5b9 Binary files /dev/null and b/public/sprites/tiles/moor1.png differ diff --git a/public/sprites/tiles/moor2.png b/public/sprites/tiles/moor2.png new file mode 100644 index 0000000..0407c6d Binary files /dev/null and b/public/sprites/tiles/moor2.png differ diff --git a/public/sprites/tiles/moor3.png b/public/sprites/tiles/moor3.png new file mode 100644 index 0000000..3eaba54 Binary files /dev/null and b/public/sprites/tiles/moor3.png differ diff --git a/public/sprites/tiles/moor4.png b/public/sprites/tiles/moor4.png new file mode 100644 index 0000000..c281035 Binary files /dev/null and b/public/sprites/tiles/moor4.png differ diff --git a/public/sprites/tiles/moor5.png b/public/sprites/tiles/moor5.png new file mode 100644 index 0000000..3e4256e Binary files /dev/null and b/public/sprites/tiles/moor5.png differ diff --git a/public/sprites/tiles/orc-bishop-cyan.png b/public/sprites/tiles/orc-bishop-cyan.png new file mode 100644 index 0000000..005fc05 Binary files /dev/null and b/public/sprites/tiles/orc-bishop-cyan.png differ diff --git a/public/sprites/tiles/orc-bishop-lime.png b/public/sprites/tiles/orc-bishop-lime.png new file mode 100644 index 0000000..bde4e5d Binary files /dev/null and b/public/sprites/tiles/orc-bishop-lime.png differ diff --git a/public/sprites/tiles/orc-bishop-purple.png b/public/sprites/tiles/orc-bishop-purple.png new file mode 100644 index 0000000..6cb34cf Binary files /dev/null and b/public/sprites/tiles/orc-bishop-purple.png differ diff --git a/public/sprites/tiles/orc-bishop-red.png b/public/sprites/tiles/orc-bishop-red.png new file mode 100644 index 0000000..ea888b2 Binary files /dev/null and b/public/sprites/tiles/orc-bishop-red.png differ diff --git a/public/sprites/tiles/orc-castle-cyan.png b/public/sprites/tiles/orc-castle-cyan.png new file mode 100644 index 0000000..e046073 Binary files /dev/null and b/public/sprites/tiles/orc-castle-cyan.png differ diff --git a/public/sprites/tiles/orc-castle-lime.png b/public/sprites/tiles/orc-castle-lime.png new file mode 100644 index 0000000..f293f17 Binary files /dev/null and b/public/sprites/tiles/orc-castle-lime.png differ diff --git a/public/sprites/tiles/orc-castle-purple.png b/public/sprites/tiles/orc-castle-purple.png new file mode 100644 index 0000000..01fc589 Binary files /dev/null and b/public/sprites/tiles/orc-castle-purple.png differ diff --git a/public/sprites/tiles/orc-castle-red.png b/public/sprites/tiles/orc-castle-red.png new file mode 100644 index 0000000..5b9ddb6 Binary files /dev/null and b/public/sprites/tiles/orc-castle-red.png differ diff --git a/public/sprites/tiles/orc-farmer-cyan.png b/public/sprites/tiles/orc-farmer-cyan.png new file mode 100644 index 0000000..c2c53eb Binary files /dev/null and b/public/sprites/tiles/orc-farmer-cyan.png differ diff --git a/public/sprites/tiles/orc-farmer-lime.png b/public/sprites/tiles/orc-farmer-lime.png new file mode 100644 index 0000000..89d81dd Binary files /dev/null and b/public/sprites/tiles/orc-farmer-lime.png differ diff --git a/public/sprites/tiles/orc-farmer-purple.png b/public/sprites/tiles/orc-farmer-purple.png new file mode 100644 index 0000000..619f0e3 Binary files /dev/null and b/public/sprites/tiles/orc-farmer-purple.png differ diff --git a/public/sprites/tiles/orc-farmer-red.png b/public/sprites/tiles/orc-farmer-red.png new file mode 100644 index 0000000..8493713 Binary files /dev/null and b/public/sprites/tiles/orc-farmer-red.png differ diff --git a/public/sprites/tiles/orc-knight-cyan.png b/public/sprites/tiles/orc-knight-cyan.png new file mode 100644 index 0000000..c95fe51 Binary files /dev/null and b/public/sprites/tiles/orc-knight-cyan.png differ diff --git a/public/sprites/tiles/orc-knight-lime.png b/public/sprites/tiles/orc-knight-lime.png new file mode 100644 index 0000000..b356948 Binary files /dev/null and b/public/sprites/tiles/orc-knight-lime.png differ diff --git a/public/sprites/tiles/orc-knight-purple.png b/public/sprites/tiles/orc-knight-purple.png new file mode 100644 index 0000000..14d4339 Binary files /dev/null and b/public/sprites/tiles/orc-knight-purple.png differ diff --git a/public/sprites/tiles/orc-knight-red.png b/public/sprites/tiles/orc-knight-red.png new file mode 100644 index 0000000..6c8d067 Binary files /dev/null and b/public/sprites/tiles/orc-knight-red.png differ diff --git a/public/sprites/tiles/ore.png b/public/sprites/tiles/ore.png new file mode 100644 index 0000000..ea512d4 Binary files /dev/null and b/public/sprites/tiles/ore.png differ diff --git a/public/sprites/tiles/pirate-bishop-cyan.png b/public/sprites/tiles/pirate-bishop-cyan.png new file mode 100644 index 0000000..96af2d8 Binary files /dev/null and b/public/sprites/tiles/pirate-bishop-cyan.png differ diff --git a/public/sprites/tiles/pirate-bishop-lime.png b/public/sprites/tiles/pirate-bishop-lime.png new file mode 100644 index 0000000..8b4d0f3 Binary files /dev/null and b/public/sprites/tiles/pirate-bishop-lime.png differ diff --git a/public/sprites/tiles/pirate-bishop-purple.png b/public/sprites/tiles/pirate-bishop-purple.png new file mode 100644 index 0000000..830499a Binary files /dev/null and b/public/sprites/tiles/pirate-bishop-purple.png differ diff --git a/public/sprites/tiles/pirate-bishop-red.png b/public/sprites/tiles/pirate-bishop-red.png new file mode 100644 index 0000000..f91014e Binary files /dev/null and b/public/sprites/tiles/pirate-bishop-red.png differ diff --git a/public/sprites/tiles/pirate-castle-cyan.png b/public/sprites/tiles/pirate-castle-cyan.png new file mode 100644 index 0000000..6bc41b6 Binary files /dev/null and b/public/sprites/tiles/pirate-castle-cyan.png differ diff --git a/public/sprites/tiles/pirate-castle-lime.png b/public/sprites/tiles/pirate-castle-lime.png new file mode 100644 index 0000000..6bc41b6 Binary files /dev/null and b/public/sprites/tiles/pirate-castle-lime.png differ diff --git a/public/sprites/tiles/pirate-castle-purple.png b/public/sprites/tiles/pirate-castle-purple.png new file mode 100644 index 0000000..6bc41b6 Binary files /dev/null and b/public/sprites/tiles/pirate-castle-purple.png differ diff --git a/public/sprites/tiles/pirate-castle-red.png b/public/sprites/tiles/pirate-castle-red.png new file mode 100644 index 0000000..6bc41b6 Binary files /dev/null and b/public/sprites/tiles/pirate-castle-red.png differ diff --git a/public/sprites/tiles/pirate-farmer-cyan.png b/public/sprites/tiles/pirate-farmer-cyan.png new file mode 100644 index 0000000..1b9adae Binary files /dev/null and b/public/sprites/tiles/pirate-farmer-cyan.png differ diff --git a/public/sprites/tiles/pirate-farmer-lime.png b/public/sprites/tiles/pirate-farmer-lime.png new file mode 100644 index 0000000..0ce43d8 Binary files /dev/null and b/public/sprites/tiles/pirate-farmer-lime.png differ diff --git a/public/sprites/tiles/pirate-farmer-purple.png b/public/sprites/tiles/pirate-farmer-purple.png new file mode 100644 index 0000000..670e28d Binary files /dev/null and b/public/sprites/tiles/pirate-farmer-purple.png differ diff --git a/public/sprites/tiles/pirate-farmer-red.png b/public/sprites/tiles/pirate-farmer-red.png new file mode 100644 index 0000000..ba9d882 Binary files /dev/null and b/public/sprites/tiles/pirate-farmer-red.png differ diff --git a/public/sprites/tiles/pirate-knight-cyan.png b/public/sprites/tiles/pirate-knight-cyan.png new file mode 100644 index 0000000..8753437 Binary files /dev/null and b/public/sprites/tiles/pirate-knight-cyan.png differ diff --git a/public/sprites/tiles/pirate-knight-lime.png b/public/sprites/tiles/pirate-knight-lime.png new file mode 100644 index 0000000..0f577c5 Binary files /dev/null and b/public/sprites/tiles/pirate-knight-lime.png differ diff --git a/public/sprites/tiles/pirate-knight-purple.png b/public/sprites/tiles/pirate-knight-purple.png new file mode 100644 index 0000000..08ae88b Binary files /dev/null and b/public/sprites/tiles/pirate-knight-purple.png differ diff --git a/public/sprites/tiles/pirate-knight-red.png b/public/sprites/tiles/pirate-knight-red.png new file mode 100644 index 0000000..19ea8ef Binary files /dev/null and b/public/sprites/tiles/pirate-knight-red.png differ diff --git a/public/sprites/tiles/rock0.png b/public/sprites/tiles/rock0.png new file mode 100644 index 0000000..0c27b04 Binary files /dev/null and b/public/sprites/tiles/rock0.png differ diff --git a/public/sprites/tiles/rock1.png b/public/sprites/tiles/rock1.png new file mode 100644 index 0000000..609e4c8 Binary files /dev/null and b/public/sprites/tiles/rock1.png differ diff --git a/public/sprites/tiles/rock2.png b/public/sprites/tiles/rock2.png new file mode 100644 index 0000000..ecfe548 Binary files /dev/null and b/public/sprites/tiles/rock2.png differ diff --git a/public/sprites/tiles/select.png b/public/sprites/tiles/select.png new file mode 100644 index 0000000..c2c7f61 Binary files /dev/null and b/public/sprites/tiles/select.png differ diff --git a/public/sprites/tiles/tree.png b/public/sprites/tiles/tree.png new file mode 100644 index 0000000..958c042 Binary files /dev/null and b/public/sprites/tiles/tree.png differ diff --git a/public/sprites/tiles/water0.png b/public/sprites/tiles/water0.png new file mode 100644 index 0000000..54a1f1e Binary files /dev/null and b/public/sprites/tiles/water0.png differ diff --git a/public/sprites/tiles/water1.png b/public/sprites/tiles/water1.png new file mode 100644 index 0000000..ac8f4c9 Binary files /dev/null and b/public/sprites/tiles/water1.png differ diff --git a/public/sprites/tiles/well.png b/public/sprites/tiles/well.png new file mode 100644 index 0000000..8079eae Binary files /dev/null and b/public/sprites/tiles/well.png differ diff --git a/public/sprites/tiles/wheat.png b/public/sprites/tiles/wheat.png new file mode 100644 index 0000000..8c8c739 Binary files /dev/null and b/public/sprites/tiles/wheat.png differ diff --git a/scripts/sync-engine.sh b/scripts/sync-engine.sh new file mode 100644 index 0000000..c5ff81b --- /dev/null +++ b/scripts/sync-engine.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Holt die Engine (src/core + die JSONs, die sie importiert) von einem festen Ninelands-Tag nach src/engine/, +# dazu Sprites (Tiles, Brett-Themen) und ausgewählte Musik nach public/. +# Die Kopie wird nie von Hand geändert – Änderungen gehören ins Hauptspiel, dann hier neu synchronisieren. +# Aufruf: scripts/sync-engine.sh [pfad-zum-hivedoku-repo] +set -euo pipefail + +tag="${1:?Tag angeben, z. B. v0.11.0}" +repo="${2:-${HIVEDOKU_REPO:-/data/Documents/projects/hivedoku}}" +ziel="$(cd "$(dirname "$0")/.." && pwd)/src/engine" + +# Nur lesen: git archive verändert das Hauptspiel-Repo nicht +commit="$(git -C "$repo" rev-parse --verify "$tag^{commit}")" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +# Musik: nur MP3 (läuft überall, auch in iOS-Webviews) und nur wenige Titel – jeder ≈ 2 MB +musik=(peaceful-village) +musik_pfade=() +for titel in "${musik[@]}"; do musik_pfade+=("public/audio/music/$titel.mp3"); done +git -C "$repo" archive "$commit" src/core src/campaign src/puzzles public/sprites/tiles public/sprites/themes \ + "${musik_pfade[@]}" | tar -x -C "$tmp" + +# Alte Kopie ersetzen, eigene Dateien (tsconfig.json, README.md) behalten +rm -rf "$ziel/core" "$ziel/campaign" "$ziel/puzzles" +mkdir -p "$ziel" +cp -r "$tmp/src/core" "$tmp/src/campaign" "$tmp/src/puzzles" "$ziel/" +find "$ziel" -name '*.test.ts' -delete + +# Assets (Nachweise in CREDITS.md) +public="$(dirname "$ziel")/../public" +rm -rf "$public/sprites" "$public/audio" +mkdir -p "$public/sprites" "$public/audio" +cp -r "$tmp/public/sprites/tiles" "$tmp/public/sprites/themes" "$public/sprites/" +cp -r "$tmp/public/audio/music" "$public/audio/" + +printf 'tag: %s\ncommit: %s\n' "$tag" "$commit" > "$ziel/VERSION" +echo "Engine synchronisiert: $tag ($commit)" diff --git a/src/client/tsconfig.json b/src/client/tsconfig.json index bbc3c0d..61c3c0a 100644 --- a/src/client/tsconfig.json +++ b/src/client/tsconfig.json @@ -5,5 +5,5 @@ "lib": ["DOM", "ES2023"], "customConditions": ["browser"] }, - "references": [{"path": "../shared/"}] + "references": [{"path": "../shared/"}, {"path": "../engine/"}] } diff --git a/src/engine/VERSION b/src/engine/VERSION new file mode 100644 index 0000000..3213522 --- /dev/null +++ b/src/engine/VERSION @@ -0,0 +1,2 @@ +tag: v0.11.0 +commit: 5909a19679bd9039655396eac34abb8d3d4c75a9 diff --git a/src/engine/campaign/campaign.json b/src/engine/campaign/campaign.json new file mode 100644 index 0000000..850844b --- /dev/null +++ b/src/engine/campaign/campaign.json @@ -0,0 +1,263 @@ +[ + { + "id": "k1-1", + "chapter": 1, + "station": 1, + "seed": 11021, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1 + }, + { + "id": "k1-2", + "chapter": 1, + "station": 2, + "seed": 12029, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1 + }, + { + "id": "k1-3", + "chapter": 1, + "station": 3, + "seed": 13010, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1 + }, + { + "id": "k2-1", + "chapter": 2, + "station": 1, + "seed": 21003, + "trait": "CHEAP_DRONES", + "generatorVersion": 1 + }, + { + "id": "k2-2", + "chapter": 2, + "station": 2, + "seed": 22017, + "trait": "CHEAP_DRONES", + "generatorVersion": 1 + }, + { + "id": "k2-3", + "chapter": 2, + "station": 3, + "seed": 23029, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1 + }, + { + "id": "k3-1", + "chapter": 3, + "station": 1, + "seed": 31002, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1 + }, + { + "id": "k3-2", + "chapter": 3, + "station": 2, + "seed": 32006, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1 + }, + { + "id": "k3-3", + "chapter": 3, + "station": 3, + "seed": 33012, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1 + }, + { + "id": "k4-1", + "chapter": 4, + "station": 1, + "seed": 41009, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1 + }, + { + "id": "k4-2", + "chapter": 4, + "station": 2, + "seed": 42009, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1 + }, + { + "id": "k4-3", + "chapter": 4, + "station": 3, + "seed": 43013, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1 + }, + { + "id": "k5-1", + "chapter": 5, + "station": 1, + "seed": 51003, + "trait": "DOUBLE_DRONE_COST", + "generatorVersion": 1 + }, + { + "id": "k5-2", + "chapter": 5, + "station": 2, + "seed": 52030, + "trait": "DOUBLE_DRONE_COST", + "generatorVersion": 1 + }, + { + "id": "k5-3", + "chapter": 5, + "station": 3, + "seed": 53017, + "trait": "CHEAP_DRONES", + "generatorVersion": 1 + }, + { + "id": "k5-4", + "chapter": 5, + "station": 4, + "seed": 229, + "trait": "DOUBLE_DRONE_COST", + "generatorVersion": 1, + "puzzle": "c1-02" + }, + { + "id": "k6-1", + "chapter": 6, + "station": 1, + "seed": 61006, + "trait": "CHEAP_DRONES", + "generatorVersion": 1 + }, + { + "id": "k6-2", + "chapter": 6, + "station": 2, + "seed": 62003, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1 + }, + { + "id": "k6-3", + "chapter": 6, + "station": 3, + "seed": 63012, + "trait": "DOUBLE_DRONE_COST", + "generatorVersion": 1 + }, + { + "id": "k6-4", + "chapter": 6, + "station": 4, + "seed": 171, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1, + "puzzle": "c1-06" + }, + { + "id": "k7-1", + "chapter": 7, + "station": 1, + "seed": 71013, + "trait": "DOUBLE_DRONE_COST", + "generatorVersion": 1 + }, + { + "id": "k7-2", + "chapter": 7, + "station": 2, + "seed": 72013, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1 + }, + { + "id": "k7-3", + "chapter": 7, + "station": 3, + "seed": 73030, + "trait": "CHEAP_DRONES", + "generatorVersion": 1 + }, + { + "id": "k7-4", + "chapter": 7, + "station": 4, + "seed": 137, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1, + "puzzle": "c2-04" + }, + { + "id": "k8-1", + "chapter": 8, + "station": 1, + "seed": 81005, + "trait": "TANDEM_MINING", + "generatorVersion": 1 + }, + { + "id": "k8-2", + "chapter": 8, + "station": 2, + "seed": 82016, + "trait": "TANDEM_MINING", + "generatorVersion": 1 + }, + { + "id": "k8-3", + "chapter": 8, + "station": 3, + "seed": 83013, + "trait": "TANDEM_MINING", + "generatorVersion": 1 + }, + { + "id": "k8-4", + "chapter": 8, + "station": 4, + "seed": 9, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "puzzle": "c3-05" + }, + { + "id": "k9-1", + "chapter": 9, + "station": 1, + "seed": 91005, + "trait": "SINGLE_WORKER_PER_ROOM", + "generatorVersion": 1 + }, + { + "id": "k9-2", + "chapter": 9, + "station": 2, + "seed": 92007, + "trait": "TANDEM_MINING", + "generatorVersion": 1 + }, + { + "id": "k9-3", + "chapter": 9, + "station": 3, + "seed": 93002, + "trait": "DOUBLE_DRONE_COST", + "generatorVersion": 1 + }, + { + "id": "k9-4", + "chapter": 9, + "station": 4, + "seed": 135, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "puzzle": "c3-08" + } +] diff --git a/src/engine/campaign/orc.json b/src/engine/campaign/orc.json new file mode 100644 index 0000000..7d031b3 --- /dev/null +++ b/src/engine/campaign/orc.json @@ -0,0 +1,326 @@ +[ + { + "id": "o1-1", + "chapter": 1, + "station": 1, + "seed": 1011008, + "trait": "CHEAP_DRONES", + "generatorVersion": 1, + "campaign": "orc" + }, + { + "id": "o1-2", + "chapter": 1, + "station": 2, + "seed": 1012029, + "trait": "NONE", + "generatorVersion": 1, + "campaign": "orc" + }, + { + "id": "o1-3", + "chapter": 1, + "station": 3, + "seed": 1013007, + "trait": "CHEAP_DRONES", + "generatorVersion": 1, + "campaign": "orc" + }, + { + "id": "o1-4", + "chapter": 1, + "station": 4, + "seed": 1014015, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1, + "campaign": "orc" + }, + { + "id": "o2-1", + "chapter": 2, + "station": 1, + "seed": 1021018, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "campaign": "orc" + }, + { + "id": "o2-2", + "chapter": 2, + "station": 2, + "seed": 1022012, + "trait": "NONE", + "generatorVersion": 1, + "campaign": "orc" + }, + { + "id": "o2-3", + "chapter": 2, + "station": 3, + "seed": 1023004, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1, + "campaign": "orc" + }, + { + "id": "o2-4", + "chapter": 2, + "station": 4, + "seed": 1024025, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "campaign": "orc" + }, + { + "id": "o3-1", + "chapter": 3, + "station": 1, + "seed": 1031030, + "trait": "NONE", + "generatorVersion": 1, + "campaign": "orc" + }, + { + "id": "o3-2", + "chapter": 3, + "station": 2, + "seed": 1032002, + "trait": "CHEAP_DRONES", + "generatorVersion": 1, + "campaign": "orc" + }, + { + "id": "o3-3", + "chapter": 3, + "station": 3, + "seed": 1033028, + "trait": "DOUBLE_DRONE_COST", + "generatorVersion": 1, + "campaign": "orc" + }, + { + "id": "o3-4", + "chapter": 3, + "station": 4, + "seed": 1034026, + "trait": "NONE", + "generatorVersion": 1, + "campaign": "orc" + }, + { + "id": "o4-1", + "chapter": 4, + "station": 1, + "seed": 1041012, + "trait": "DOUBLE_DRONE_COST", + "generatorVersion": 1, + "campaign": "orc" + }, + { + "id": "o4-2", + "chapter": 4, + "station": 2, + "seed": 1042007, + "trait": "NONE", + "generatorVersion": 1, + "campaign": "orc" + }, + { + "id": "o4-3", + "chapter": 4, + "station": 3, + "seed": 1043002, + "trait": "SINGLE_WORKER_PER_ROOM", + "generatorVersion": 1, + "campaign": "orc" + }, + { + "id": "o4-4", + "chapter": 4, + "station": 4, + "seed": 1044006, + "trait": "CHEAP_DRONES", + "generatorVersion": 1, + "campaign": "orc" + }, + { + "id": "o5-1", + "chapter": 5, + "station": 1, + "seed": 1051016, + "trait": "NONE", + "generatorVersion": 1, + "campaign": "orc" + }, + { + "id": "o5-2", + "chapter": 5, + "station": 2, + "seed": 1052019, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1, + "campaign": "orc" + }, + { + "id": "o5-3", + "chapter": 5, + "station": 3, + "seed": 1053013, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "campaign": "orc" + }, + { + "id": "o5-4", + "chapter": 5, + "station": 4, + "seed": 1054002, + "trait": "DOUBLE_DRONE_COST", + "generatorVersion": 1, + "campaign": "orc" + }, + { + "id": "o6-1", + "chapter": 6, + "station": 1, + "seed": 1061027, + "trait": "NONE", + "generatorVersion": 1, + "campaign": "orc" + }, + { + "id": "o6-2", + "chapter": 6, + "station": 2, + "seed": 1062012, + "trait": "CHEAP_DRONES", + "generatorVersion": 1, + "campaign": "orc" + }, + { + "id": "o6-3", + "chapter": 6, + "station": 3, + "seed": 1063021, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1, + "campaign": "orc" + }, + { + "id": "o6-4", + "chapter": 6, + "station": 4, + "seed": 1064026, + "trait": "SINGLE_WORKER_PER_ROOM", + "generatorVersion": 1, + "campaign": "orc" + }, + { + "id": "o7-1", + "chapter": 7, + "station": 1, + "seed": 1071009, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "campaign": "orc" + }, + { + "id": "o7-2", + "chapter": 7, + "station": 2, + "seed": 1072004, + "trait": "DOUBLE_DRONE_COST", + "generatorVersion": 1, + "campaign": "orc" + }, + { + "id": "o7-3", + "chapter": 7, + "station": 3, + "seed": 1073004, + "trait": "NONE", + "generatorVersion": 1, + "campaign": "orc" + }, + { + "id": "o7-4", + "chapter": 7, + "station": 4, + "seed": 1074002, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "campaign": "orc" + }, + { + "id": "o8-1", + "chapter": 8, + "station": 1, + "seed": 1081026, + "trait": "SINGLE_WORKER_PER_ROOM", + "generatorVersion": 1, + "campaign": "orc" + }, + { + "id": "o8-2", + "chapter": 8, + "station": 2, + "seed": 1082026, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1, + "campaign": "orc" + }, + { + "id": "o8-3", + "chapter": 8, + "station": 3, + "seed": 1083010, + "trait": "SINGLE_WORKER_PER_ROOM", + "generatorVersion": 1, + "campaign": "orc" + }, + { + "id": "o8-4", + "chapter": 8, + "station": 4, + "seed": 1084022, + "trait": "DOUBLE_DRONE_COST", + "generatorVersion": 1, + "campaign": "orc" + }, + { + "id": "o9-1", + "chapter": 9, + "station": 1, + "seed": 1091007, + "trait": "DOUBLE_DRONE_COST", + "generatorVersion": 1, + "campaign": "orc" + }, + { + "id": "o9-2", + "chapter": 9, + "station": 2, + "seed": 1092024, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "campaign": "orc" + }, + { + "id": "o9-3", + "chapter": 9, + "station": 3, + "seed": 1093007, + "trait": "SINGLE_WORKER_PER_ROOM", + "generatorVersion": 1, + "campaign": "orc" + }, + { + "id": "o9-4", + "chapter": 9, + "station": 4, + "seed": 1094009, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1, + "campaign": "orc" + } +] diff --git a/src/engine/campaign/pirate.json b/src/engine/campaign/pirate.json new file mode 100644 index 0000000..5d78f09 --- /dev/null +++ b/src/engine/campaign/pirate.json @@ -0,0 +1,326 @@ +[ + { + "id": "p1-1", + "chapter": 1, + "station": 1, + "seed": 2011027, + "trait": "NONE", + "generatorVersion": 1, + "campaign": "pirate" + }, + { + "id": "p1-2", + "chapter": 1, + "station": 2, + "seed": 2012026, + "trait": "CHEAP_DRONES", + "generatorVersion": 1, + "campaign": "pirate" + }, + { + "id": "p1-3", + "chapter": 1, + "station": 3, + "seed": 2013007, + "trait": "NONE", + "generatorVersion": 1, + "campaign": "pirate" + }, + { + "id": "p1-4", + "chapter": 1, + "station": 4, + "seed": 2014007, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1, + "campaign": "pirate" + }, + { + "id": "p2-1", + "chapter": 2, + "station": 1, + "seed": 2021022, + "trait": "CHEAP_DRONES", + "generatorVersion": 1, + "campaign": "pirate" + }, + { + "id": "p2-2", + "chapter": 2, + "station": 2, + "seed": 2022010, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1, + "campaign": "pirate" + }, + { + "id": "p2-3", + "chapter": 2, + "station": 3, + "seed": 2023016, + "trait": "NONE", + "generatorVersion": 1, + "campaign": "pirate" + }, + { + "id": "p2-4", + "chapter": 2, + "station": 4, + "seed": 2024013, + "trait": "DOUBLE_DRONE_COST", + "generatorVersion": 1, + "campaign": "pirate" + }, + { + "id": "p3-1", + "chapter": 3, + "station": 1, + "seed": 2031001, + "trait": "NONE", + "generatorVersion": 1, + "campaign": "pirate" + }, + { + "id": "p3-2", + "chapter": 3, + "station": 2, + "seed": 2032005, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1, + "campaign": "pirate" + }, + { + "id": "p3-3", + "chapter": 3, + "station": 3, + "seed": 2033024, + "trait": "CHEAP_DRONES", + "generatorVersion": 1, + "campaign": "pirate" + }, + { + "id": "p3-4", + "chapter": 3, + "station": 4, + "seed": 2034027, + "trait": "NONE", + "generatorVersion": 1, + "campaign": "pirate" + }, + { + "id": "p4-1", + "chapter": 4, + "station": 1, + "seed": 2041001, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "campaign": "pirate" + }, + { + "id": "p4-2", + "chapter": 4, + "station": 2, + "seed": 2042011, + "trait": "NONE", + "generatorVersion": 1, + "campaign": "pirate" + }, + { + "id": "p4-3", + "chapter": 4, + "station": 3, + "seed": 2043010, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "campaign": "pirate" + }, + { + "id": "p4-4", + "chapter": 4, + "station": 4, + "seed": 2044004, + "trait": "DOUBLE_DRONE_COST", + "generatorVersion": 1, + "campaign": "pirate" + }, + { + "id": "p5-1", + "chapter": 5, + "station": 1, + "seed": 2051013, + "trait": "DOUBLE_DRONE_COST", + "generatorVersion": 1, + "campaign": "pirate" + }, + { + "id": "p5-2", + "chapter": 5, + "station": 2, + "seed": 2052030, + "trait": "NONE", + "generatorVersion": 1, + "campaign": "pirate" + }, + { + "id": "p5-3", + "chapter": 5, + "station": 3, + "seed": 2053022, + "trait": "CHEAP_DRONES", + "generatorVersion": 1, + "campaign": "pirate" + }, + { + "id": "p5-4", + "chapter": 5, + "station": 4, + "seed": 2054003, + "trait": "SINGLE_WORKER_PER_ROOM", + "generatorVersion": 1, + "campaign": "pirate" + }, + { + "id": "p6-1", + "chapter": 6, + "station": 1, + "seed": 2061007, + "trait": "NONE", + "generatorVersion": 1, + "campaign": "pirate" + }, + { + "id": "p6-2", + "chapter": 6, + "station": 2, + "seed": 2062010, + "trait": "SINGLE_WORKER_PER_ROOM", + "generatorVersion": 1, + "campaign": "pirate" + }, + { + "id": "p6-3", + "chapter": 6, + "station": 3, + "seed": 2063005, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1, + "campaign": "pirate" + }, + { + "id": "p6-4", + "chapter": 6, + "station": 4, + "seed": 2064004, + "trait": "CHEAP_DRONES", + "generatorVersion": 1, + "campaign": "pirate" + }, + { + "id": "p7-1", + "chapter": 7, + "station": 1, + "seed": 2071008, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "campaign": "pirate" + }, + { + "id": "p7-2", + "chapter": 7, + "station": 2, + "seed": 2072003, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1, + "campaign": "pirate" + }, + { + "id": "p7-3", + "chapter": 7, + "station": 3, + "seed": 2073005, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "campaign": "pirate" + }, + { + "id": "p7-4", + "chapter": 7, + "station": 4, + "seed": 2074013, + "trait": "NONE", + "generatorVersion": 1, + "campaign": "pirate" + }, + { + "id": "p8-1", + "chapter": 8, + "station": 1, + "seed": 2081013, + "trait": "DOUBLE_DRONE_COST", + "generatorVersion": 1, + "campaign": "pirate" + }, + { + "id": "p8-2", + "chapter": 8, + "station": 2, + "seed": 2082003, + "trait": "SINGLE_WORKER_PER_ROOM", + "generatorVersion": 1, + "campaign": "pirate" + }, + { + "id": "p8-3", + "chapter": 8, + "station": 3, + "seed": 2083020, + "trait": "NONE", + "generatorVersion": 1, + "campaign": "pirate" + }, + { + "id": "p8-4", + "chapter": 8, + "station": 4, + "seed": 2084025, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1, + "campaign": "pirate" + }, + { + "id": "p9-1", + "chapter": 9, + "station": 1, + "seed": 2091022, + "trait": "SINGLE_WORKER_PER_ROOM", + "generatorVersion": 1, + "campaign": "pirate" + }, + { + "id": "p9-2", + "chapter": 9, + "station": 2, + "seed": 2092018, + "trait": "DOUBLE_DRONE_COST", + "generatorVersion": 1, + "campaign": "pirate" + }, + { + "id": "p9-3", + "chapter": 9, + "station": 3, + "seed": 2093001, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "campaign": "pirate" + }, + { + "id": "p9-4", + "chapter": 9, + "station": 4, + "seed": 2094016, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1, + "campaign": "pirate" + } +] diff --git a/src/engine/core/Biomes.ts b/src/engine/core/Biomes.ts new file mode 100644 index 0000000..2b5de8e --- /dev/null +++ b/src/engine/core/Biomes.ts @@ -0,0 +1,41 @@ +/** + * Biomes.ts + * Regeldaten der Zonen-Biome (Texte stehen in src/i18n): + * - 'MOUNTAIN' (Gebirge): Verdoppelt ZoI-Kosten (Drohne RW 1, Scout max. 1 Feld). Einziges Biom für Erz-Vorkommen! 2 Gebirge dürfen nicht aneinandergrenzen. + * - 'SWAMP' (Sumpf): Verdoppelt ZoI-Kosten (wie Gebirge). Keine Hivemother, kein Hiveworker baubar! Beherbergt Ur-Biomasse (+20 Pkt). Max. 1 Sumpfzone bei 9x9. + * - 'PLAINS' (Waben-Ebene): Standard-Wabengelände mit regulären Bedingungen. + */ + +import type { Resource } from './Board'; + +export type BiomeType = 'MOUNTAIN' | 'SWAMP' | 'PLAINS'; + +export interface BiomeDef { + id: BiomeType; + color: string; + /** ZoI-Kosten pro Feld (Gebirge & Sumpf verdoppeln auf 2) */ + zoiCost: number; + /** Einziger Rohstoff, der in diesem Biom vorkommt (Bauverbote stehen in UNIT_DEFS) */ + resource: Resource | null; +} + +export const BIOMES: Record = { + MOUNTAIN: { + id: 'MOUNTAIN', + color: '#94a3b8', + zoiCost: 2, + resource: 'ORE', + }, + SWAMP: { + id: 'SWAMP', + color: '#a855f7', + zoiCost: 2, + resource: 'BIOMASS', + }, + PLAINS: { + id: 'PLAINS', + color: '#10b981', + zoiCost: 1, + resource: null, + }, +}; diff --git a/src/engine/core/Board.ts b/src/engine/core/Board.ts new file mode 100644 index 0000000..2c57ac0 --- /dev/null +++ b/src/engine/core/Board.ts @@ -0,0 +1,216 @@ +/** + * Board.ts + * Unveränderliches Gelände einer Map: Zonen, Biome, Seen, Rohstoffe. + * Alles liegt in flachen Arrays mit Index i = y * SIZE + x. + * Die ZoI jeder Einheit auf jedem Feld wird einmalig vorberechnet. + */ + +import { BIOMES } from './Biomes'; +import type { BiomeType } from './Biomes'; +import type { WorldTrait } from './WorldRules'; +import { UNIT_DEFS, UNIT_TYPES, castZoI } from './units'; +import type { Terrain, UnitType } from './units'; +import { mapCodeFromSeed } from './rng'; + +/** Kartengrößen: klein 9 × 9 (9 Lande), mittel 12 × 12 (16), groß 15 × 15 (25) – je Land ~9 Felder */ +export const MAP_SIZES = [9, 12, 15] as const; +export type MapSize = (typeof MAP_SIZES)[number]; + +/** Standardgröße (Feldzug, Rätsel, Tageskarte, Online-Duelle); die Konstanten unten gelten für sie */ +export const SIZE = 9; +export const CELLS = SIZE * SIZE; + +/** Lande je Kartengröße */ +export function zoneCountFor(size: number): number { + return Math.round((size * size) / 9); +} + +/** Map-Code-Zusatz je Größe („-M“ mittel, „-L“ groß; klein ohne – alte Codes bleiben gültig) */ +export function sizeSuffix(size: number): string { + return size === 12 ? '-M' : size === 15 ? '-L' : ''; +} + +/** Größe aus einem Map-Code („1Z141Z3-M“ → 12) */ +export function sizeFromMapId(mapId: string): MapSize { + const id = mapId.trim().toUpperCase(); + return id.endsWith('-M') ? 12 : id.endsWith('-L') ? 15 : 9; +} + +/** Map-Code ohne Größen-Zusatz (für den Seed) */ +export function baseMapId(mapId: string): string { + const id = mapId.trim(); + return /-[ML]$/i.test(id) ? id.slice(0, -2) : id; +} + +/** Nachbarschaften einer Kartengröße (je Größe einmal berechnet) */ +export interface Geometry { + size: number; + cells: number; + orth: readonly (readonly number[])[]; + ring: readonly (readonly number[])[]; +} + +const ORTH_DIRS = [ + [1, 0], + [-1, 0], + [0, 1], + [0, -1], +] as const; + +const RING_DIRS = [ + [-1, -1], + [0, -1], + [1, -1], + [-1, 0], + [1, 0], + [-1, 1], + [0, 1], + [1, 1], +] as const; + +const geometries = new Map(); + +export function geometry(size: number): Geometry { + let g = geometries.get(size); + if (!g) { + g = { size, cells: size * size, orth: neighborLists(size, ORTH_DIRS), ring: neighborLists(size, RING_DIRS) }; + geometries.set(size, g); + } + return g; +} + +/** Besiedlungspunkte eines Ebenen-Felds ohne bzw. mit See in der Zone. */ +export const SETTLE_POINTS = 5; +export const SETTLE_POINTS_LAKE = 7; + +export const idx = (x: number, y: number): number => y * SIZE + x; +export const xOf = (i: number): number => i % SIZE; +export const yOf = (i: number): number => (i / SIZE) | 0; + +export type Resource = 'ORE' | 'BIOMASS'; + +function neighborLists(size: number, dirs: readonly (readonly [number, number])[]): number[][] { + const lists: number[][] = []; + for (let i = 0; i < size * size; i++) { + const x = i % size; + const y = (i / size) | 0; + const list: number[] = []; + for (const [dx, dy] of dirs) { + const nx = x + dx; + const ny = y + dy; + if (nx >= 0 && nx < size && ny >= 0 && ny < size) list.push(ny * size + nx); + } + lists.push(list); + } + return lists; +} + +/** Orthogonale Nachbarn je Feld (9 × 9; andere Größen: board.orth). */ +export const ORTH_NEIGHBORS = geometry(SIZE).orth; + +/** Alle 8 Nachbarn je Feld (9 × 9; andere Größen: board.ring). */ +export const RING_NEIGHBORS = geometry(SIZE).ring; + +export interface BoardSpec { + seed: number; + trait: WorldTrait; + /** Zonen-Index je Feld (0 … zoneCount-1) */ + zone: ArrayLike; + /** Biom je Zone */ + zoneBiome: readonly BiomeType[]; + /** Feld-Indizes mit See */ + lakes?: Iterable; + /** Rohstoff je Feld-Index */ + resources?: Iterable<[number, Resource]>; + /** Kantenlänge (Standard 9) */ + size?: number; +} + +export class Board implements Terrain { + readonly size: number; + /** Anzahl Felder (size²) */ + readonly cells: number; + /** Nachbarn je Feld: orthogonal und alle 8 */ + readonly orth: readonly (readonly number[])[]; + readonly ring: readonly (readonly number[])[]; + readonly seed: number; + readonly trait: WorldTrait; + readonly zone: Uint8Array; + readonly zoneCount: number; + readonly zoneBiome: readonly BiomeType[]; + readonly lake: Uint8Array; + readonly resource: (Resource | null)[]; + /** Feld-Indizes je Zone */ + readonly zoneCells: readonly (readonly number[])[]; + readonly zoneLakeCount: readonly number[]; + /** Vorberechnete ZoI: zoiTable[unit][i] = abgedeckte Felder */ + private readonly zoiTable: Record; + + constructor(spec: BoardSpec) { + const g = geometry(spec.size ?? SIZE); + this.size = g.size; + this.cells = g.cells; + this.orth = g.orth; + this.ring = g.ring; + this.lake = new Uint8Array(g.cells); + this.resource = new Array(g.cells).fill(null); + this.seed = spec.seed >>> 0; + this.trait = spec.trait; + this.zone = Uint8Array.from(spec.zone); + this.zoneBiome = spec.zoneBiome.slice(); + this.zoneCount = this.zoneBiome.length; + + for (const i of spec.lakes ?? []) this.lake[i] = 1; + for (const [i, r] of spec.resources ?? []) this.resource[i] = r; + + const zoneCells: number[][] = Array.from({ length: this.zoneCount }, () => []); + for (let i = 0; i < this.cells; i++) zoneCells[this.zone[i]].push(i); + this.zoneCells = zoneCells; + this.zoneLakeCount = zoneCells.map((cells) => cells.filter((i) => this.lake[i]).length); + + const table = {} as Record; + for (const unit of UNIT_TYPES) { + const def = UNIT_DEFS[unit]; + table[unit] = Array.from({ length: this.cells }, (_, i) => castZoI(def, this, i)); + } + this.zoiTable = table; + } + + /** Map-Code (Seed in Base36, bei größeren Karten mit Größen-Zusatz) */ + get mapCode(): string { + return mapCodeFromSeed(this.seed) + sizeSuffix(this.size); + } + + /** Spalte, Zeile und Index in dieser Kartengröße */ + x(i: number): number { + return i % this.size; + } + + y(i: number): number { + return (i / this.size) | 0; + } + + at(x: number, y: number): number { + return y * this.size + x; + } + + biomeAt(i: number): BiomeType { + return this.zoneBiome[this.zone[i]]; + } + + zoiCostAt(i: number): number { + return BIOMES[this.biomeAt(i)].zoiCost; + } + + /** Felder, die eine Einheit auf Feld i abdecken würde. */ + zoiOf(unit: UnitType, i: number): readonly number[] { + return this.zoiTable[unit][i]; + } + + /** + * Besiedlungspunkte in einer Ebenen-Zone: Basis, mit mindestens einem See erhöht (Seen stapeln nicht). + */ + settlementPoints(zone: number): number { + return this.zoneLakeCount[zone] > 0 ? SETTLE_POINTS_LAKE : SETTLE_POINTS; + } +} diff --git a/src/engine/core/Game.ts b/src/engine/core/Game.ts new file mode 100644 index 0000000..5cec8a9 --- /dev/null +++ b/src/engine/core/Game.ts @@ -0,0 +1,958 @@ +/** + * Game.ts + * Spielzustand und Regeln einer Partie – DOM-frei, deterministisch und schnell. + * + * - ZoI wird inkrementell gezählt (zoiCount), statt bei jeder Abfrage neu berechnet zu werden. + * - legalMoves() liefert eine 81-Bit-Karte gültiger Felder und wird pro Zustand gecacht. + * - Platzierungsfehler sind Codes, keine Texte (die UI übersetzt sie). + * - Mehrspieler (1v1): gemeinsames Brett, eigene Energie/Einheiten je Spieler. Gebaut werden darf in + * keiner ZoI (weder eigener noch fremder), andocken nur an die eigene ZoI. Solo = 1 Spieler, Regeln + * und Ergebnisse sind dort identisch zur Einzelspieler-Engine. + */ + +import { mulberry32 } from './rng'; +import type { Board, Resource } from './Board'; +import { RESOURCE_DEFS } from './Types'; +import { UNIT_BY_CODE, UNIT_CODE, UNIT_DEFS, UNIT_TYPES } from './units'; +import type { UnitType } from './units'; +import { WORLD_RULES, getPieceCost } from './WorldRules'; + +export type PlacementError = + | 'UNIT_LIMIT' + | 'NOT_AFFORDABLE' + | 'OCCUPIED' + | 'LAKE' + | 'BIOME_FORBIDDEN' + | 'ZONE_UNIT_LIMIT' + | 'NO_MOTHER' + | 'IN_ZOI' + /** Regeln „realms“: hier hältst du das Land schon selbst */ + | 'OWN_LAND' + /** Regeln „realms“: fremdes Land, aber du wärst hier nicht stärker als der Gegner */ + | 'TOO_WEAK' + /** Nebel des Krieges: dieses Feld ist noch nicht aufgedeckt */ + | 'HIDDEN' + | 'DOCK_OTHER_ZONE' + | 'NOT_DOCKED' + /** Rätsel: diese Einheit ist hier nicht erlaubt */ + | 'UNIT_NOT_ALLOWED' + /** Rätsel: alle erlaubten Züge sind verbraucht */ + | 'MOVE_LIMIT'; + +export type PlacementCheck = { ok: true; cost: number } | { ok: false; error: PlacementError; cost: number }; + +export interface MinedItem { + cell: number; + resource: Resource; + points: number; + multiplier: number; +} + +/** Ertrag einer Platzierung (ohne Baukosten). */ +export type PlacementYield = + /** Ebene besiedelt: Basispunkte (5 bzw. 7 mit See) × Multiplikator (Drohne 2x) */ + | { kind: 'settled'; points: number; basePoints: number; lakeCount: number; multiplier: number } + /** Tandem-Abbau ausgelöst (≥ 2 Steine in der Zone): alle ungeminten Rohstoffe der Zone */ + | { kind: 'tandemMined'; points: number; mined: MinedItem[] } + /** Tandem-Bedingung erfüllt, aber kein ungeminter Rohstoff in der Zone */ + | { kind: 'tandemNothing'; points: 0 } + /** Tandem aktiv, erst 1 Stein in der Zone: Rohstoff wartet auf 2. Stein */ + | { kind: 'tandemPending'; points: 0; resource: Resource; potentialPoints: number } + /** Direkter Rohstoffabbau (Gebirge & Sumpf) */ + | { kind: 'mined'; points: number; item: MinedItem } + /** Kein Ertrag */ + | { kind: 'none'; points: 0 }; + +export interface Move { + unit: UnitType; + cell: number; +} + +/** Was ein Zug an Land bewegt (Regeln „realms“; bei „classic“ leer) */ +export interface Conquest { + /** Zug in fremdes oder umkämpftes Land */ + attack: boolean; + /** Felder, die vorher der Gegner hielt und jetzt der Ziehende */ + captured: number[]; + /** Landstriche, in denen der Ziehende jetzt herrscht (vorher nicht) */ + won: number[]; + /** Landstriche, die der Gegner dabei verloren hat */ + taken: number[]; + /** Räuberlager, die dieser Zug ausgehoben hat (Feld des Lagers) */ + camps: number[]; + /** Beute aus ausgehobenen Lagern (schon im Gold enthalten) */ + loot: number; +} + +export type PlacementResult = + /** score = Energie des ziehenden Spielers nach dem Zug */ + | { + ok: true; + move: Move; + player: number; + cost: number; + yield: PlacementYield; + score: number; + conquest: Conquest; + } + | { ok: false; error: PlacementError; cost: number }; + +/** Was ein Zug bewirken würde – ohne den Spielzustand zu ändern. */ +export type PlacementPreview = + | { + ok: true; + cost: number; + yield: PlacementYield; + /** Punkteänderung inkl. Baukosten (Ertrag − Kosten) */ + net: number; + /** Felder, die die neue Einheit mit ihrer ZoI abdecken würde */ + zoi: readonly number[]; + conquest: Conquest; + } + | { ok: false; error: PlacementError; cost: number }; + +export interface GameOptions { + /** Wie viele Züge rückgängig gemacht werden können (Standard 1, Infinity = unbegrenzt) */ + undoLimit?: number; + /** Anzahl Spieler: 1 = Solo (Standard), 2 = 1v1 */ + players?: 1 | 2; + /** + * Zugreihenfolge im 1v1: + * - 'snake' (Standard): A setzt Mutter, B setzt Mutter, B baut zuerst, danach abwechselnd + * - 'alternate': streng abwechselnd ab A (Vergleichswert für die Messung des Anzugsvorteils) + */ + opening?: Opening; + /** Rätsel: nur diese Einheiten dürfen gebaut werden (Standard: alle) */ + allowedUnits?: readonly UnitType[]; + /** Regelwerk (Standard 'classic'); siehe RulesVersion */ + rules?: RulesVersion; + /** Räuberlager auf der Karte (neutral, mit Stärke; ausheben bringt Beute) */ + bandits?: boolean; + /** Nebel des Krieges: bauen nur auf aufgedeckten Feldern, Figuren decken ihre Umgebung auf */ + fog?: boolean; + /** Gunst-Boni des Eroberungszugs (Issue #67): billigere Figuren, mehr Ertrag, Startgold, Beute */ + boons?: Boons; +} + +/** + * Gunst im Eroberungszug (Issue #67, Phase 6) – verändert Kosten und Erträge, nie die Regeln des Bauens. + * Alle Werte summieren sich (mehrfach gewählte Gunst wirkt mehrfach). + */ +export interface Boons { + /** Kosten je Figur verringern (nie unter 0) */ + discount?: Partial>; + /** Mehr Gold je bewirtschaftetem Weideland (vor dem Ritter-Faktor) */ + pasture?: number; + /** Mehr Gold je abgebautem Schatz (vor dem Ritter-Faktor) */ + treasure?: number; + /** Gold zu Beginn der Karte */ + startGold?: number; + /** Mehr Beute je ausgehobenem Räuberlager */ + loot?: number; +} + +export type Opening = 'snake' | 'alternate'; + +/** + * Regelwerk – wird mit Duellen gespeichert, damit alte Partien nach ihren Regeln nachgespielt werden: + * - 'classic': in beanspruchtem Land darf niemand bauen; im 1v1 gewinnt das meiste Gold. + * - 'realms': jede Figur hat Stärke (auf ihrem Feld und ihrem Land); ein Feld gehört dem Stärkeren. + * Im 1v1 darf man in fremdem Land bauen, wenn man dort danach stärker ist („Angriff“); + * es gewinnt, wer über mehr Landstriche herrscht (Gleichstand: Gold). + * Im Solo ändert 'realms' am Bauen nichts – Herrschaft zählt dort für die Ziele. + * - 'realms2': wie 'realms', aber der zweite Spieler beginnt mit SECOND_PLAYER_GOLD Gold – gleicht den + * Anzugsvorteil aus (Bot-Messung: 58/42 → 50/50 auf 9 × 9, Issue #92). + */ +export type RulesVersion = 'classic' | 'realms' | 'realms2'; + +/** Regelwerk neuer 1v1-Partien (lokal und online); laufende Online-Duelle behalten ihres (DuelRecord.rules). */ +export const MATCH_RULES: RulesVersion = 'realms2'; + +/** Startgold des zweiten Spielers unter 'realms2' (Issue #92) */ +export const SECOND_PLAYER_GOLD = 5; + +const NO_CONQUEST: Conquest = { attack: false, captured: [], won: [], taken: [], camps: [], loot: 0 }; + +/** holder(): Feld gehört Räubern */ +export const BANDIT = -3; +/** Räuberlager: Stärke auf dem Lager und auf den 8 Feldern ringsum, Beute beim Ausheben */ +export const CAMP_STRENGTH = 3; +export const CAMP_RING_STRENGTH = 2; +export const CAMP_LOOT = 25; +/** Lager je Karte (9 × 9) und Mindestabstand zwischen Lagern (Felder, Schachbrett-Abstand) */ +const CAMP_COUNT = 3; +const CAMP_SPACING = 3; + +/** Nebel des Krieges: Sichtweite um eigene Figuren und um gehaltenes Land */ +const SIGHT_PIECE = 2; +const SIGHT_LAND = 1; + +/** Spieler, der nach `moves` gespielten Zügen planmäßig dran ist (ohne Passen). */ +export function scheduledPlayer(moves: number, players: number, opening: Opening = 'snake'): number { + if (players === 1) return 0; + if (opening === 'alternate' || moves === 0) return moves % 2; + // snake: Zug 0 = A, 1 = B, 2 = B, 3 = A, 4 = B, … + return moves === 1 || moves % 2 === 0 ? 1 : 0; +} + +interface Snapshot { + move: Move; + player: number; + piece: Uint8Array; + owner: Uint8Array; + mined: Uint8Array; + zoiCount: Uint8Array; + zoiMask: Uint8Array; + zoiOwn: Uint8Array[]; + strength: Int16Array[]; + campAlive: Uint8Array; + visible: Uint8Array[]; + counts: number[]; + scores: number[]; +} + +/** Plätze je Spieler im counts-Array (UNIT_CODE 1–4, 0 = leer) */ +const UNIT_SLOTS = 5; + +export class Game { + readonly board: Board; + /** Einheit je Feld (UNIT_CODE, 0 = leer) */ + piece: Uint8Array; + /** 1 = Rohstoff auf diesem Feld wurde abgebaut */ + mined: Uint8Array; + /** Wie viele Einheiten dieses Feld mit ihrer ZoI abdecken */ + zoiCount: Uint8Array; + /** Bitmaske (1 << UNIT_CODE) der Einheiten-Typen, deren ZoI dieses Feld abdeckt */ + zoiMask: Uint8Array; + /** Besitzer je Feld (Spieler + 1, 0 = leer) */ + owner: Uint8Array; + /** ZoI-Zähler je Spieler (nur im Mehrspieler; Solo nutzt zoiCount) – fürs Andocken */ + zoiOwn: Uint8Array[]; + /** + * Stärke je Spieler und Feld (Regeln „realms“ im Mehrspieler oder mit Räubern; sonst leer). + * Mit Räubern ist der letzte Eintrag (Index players) die Stärke der Räuber. + */ + strength: Int16Array[]; + readonly rules: RulesVersion; + readonly bandits: boolean; + readonly fog: boolean; + /** Gunst des Eroberungszugs (Issue #67) oder null */ + readonly boons: Boons | null; + /** Felder der Räuberlager (fest je Karte) und ob sie noch stehen (1) */ + readonly camps: number[] = []; + campAlive: Uint8Array = new Uint8Array(0); + /** Nebel des Krieges: aufgedeckte Felder je Spieler (1 = sichtbar; sonst leer) */ + visible: Uint8Array[] = []; + /** Energie je Spieler */ + scores: number[]; + /** Spieler am Zug (0-basiert) */ + current = 0; + readonly players: number; + readonly opening: Opening; + /** Rätsel: nur diese Einheiten (null = alle); ändern mit setAllowedUnits() */ + allowedUnits: readonly UnitType[] | null; + /** Rätsel: höchstens so viele Züge insgesamt (moves.length), null = unbegrenzt */ + moveLimit: number | null = null; + + /** Einheiten je Spieler: counts[spieler * UNIT_SLOTS + UNIT_CODE] */ + private counts: number[]; + /** Alle Züge der aktuellen Stellung in Reihenfolge (Undo entfernt den letzten) – für die Server-Prüfung */ + private log: Move[] = []; + private undoLimit: number; + private history: Snapshot[] = []; + private version = 0; + private legalCache = new Map(); + private heldCache = new Map(); + + constructor(board: Board, options: GameOptions = {}) { + this.board = board; + const cells = board.cells; + this.piece = new Uint8Array(cells); + this.mined = new Uint8Array(cells); + this.zoiCount = new Uint8Array(cells); + this.zoiMask = new Uint8Array(cells); + this.owner = new Uint8Array(cells); + this.undoLimit = options.undoLimit ?? 1; + this.players = options.players ?? 1; + this.opening = options.opening ?? 'snake'; + this.allowedUnits = options.allowedUnits ?? null; + this.scores = new Array(this.players).fill(0); + this.counts = new Array(this.players * UNIT_SLOTS).fill(0); + this.zoiOwn = this.players > 1 ? Array.from({ length: this.players }, () => new Uint8Array(cells)) : []; + this.rules = options.rules ?? 'classic'; + this.bandits = options.bandits ?? false; + this.fog = options.fog ?? false; + this.boons = options.boons ?? null; + if (this.boons?.startGold) this.scores[0] = this.boons.startGold; + if (this.rules === 'realms2' && this.players === 2) this.scores[1] = SECOND_PLAYER_GOLD; + const layers = this.players + (this.bandits ? 1 : 0); + this.strength = this.usesStrength ? Array.from({ length: layers }, () => new Int16Array(cells)) : []; + if (this.bandits) this.placeCamps(); + if (this.fog) this.visible = this.startVisibility(); + } + + /** Kann Land den Besitzer wechseln? (Regeln „realms“ mit mehreren Spielern) */ + get contested(): boolean { + return this.rules !== 'classic' && this.players > 1; + } + + /** Gilt Stärke (fremdes Land erobern)? Im Duell nach „realms“ und überall, wo Räuber stehen. */ + get usesStrength(): boolean { + return this.contested || this.bandits; + } + + /** Lebende Räuberlager (Feld-Indizes) */ + get liveCamps(): number[] { + return this.camps.filter((_, k) => this.campAlive[k] === 1); + } + + /** Räuberlager: deterministisch aus dem Karten-Seed, nie auf Seen oder Schätzen, mit Abstand zueinander. */ + private placeCamps(): void { + const board = this.board; + const rng = mulberry32((board.seed ^ 0xbad1) >>> 0); + const candidates: number[] = []; + for (let i = 0; i < board.cells; i++) if (!board.lake[i] && !board.resource[i]) candidates.push(i); + // 3 Lager je 81 Felder (größere Karten entsprechend mehr) + const count = Math.round((CAMP_COUNT * board.cells) / 81); + shuffle(candidates, rng); + const camps = this.camps as number[]; + for (const c of candidates) { + if (camps.length >= count) break; + const far = (o: number) => + Math.max(Math.abs(board.x(o) - board.x(c)), Math.abs(board.y(o) - board.y(c))) >= CAMP_SPACING; + if (!camps.every(far)) continue; + camps.push(c); + } + this.campAlive = new Uint8Array(camps.length).fill(1); + const bandit = this.strength[this.players]; + for (const c of camps) { + bandit[c] += CAMP_STRENGTH; + for (const n of board.ring[c]) if (!board.lake[n]) bandit[n] += CAMP_RING_STRENGTH; + } + } + + /** + * Nebel des Krieges zu Beginn: jeder Spieler sieht ein Startland (Ebene, deterministisch aus dem Seed) samt + * einem Feld Rand. Im 1v1 liegt das zweite Startland möglichst weit vom ersten entfernt. + */ + private startVisibility(): Uint8Array[] { + const board = this.board; + const zones = [...Array(board.zoneCount).keys()]; + const plains = zones.filter((z) => board.zoneBiome[z] === 'PLAINS'); + const pool = plains.length > 0 ? plains : zones; + const centre = (z: number) => { + const cells = board.zoneCells[z]; + return [ + cells.reduce((a, c) => a + board.x(c), 0) / cells.length, + cells.reduce((a, c) => a + board.y(c), 0) / cells.length, + ]; + }; + const starts = [pool[(board.seed >>> 0) % pool.length]]; + for (let p = 1; p < this.players; p++) { + const [ax, ay] = centre(starts[0]); + let best = pool[0]; + let bestD = -1; + for (const z of pool) { + if (starts.includes(z)) continue; + const [x, y] = centre(z); + const d = (x - ax) ** 2 + (y - ay) ** 2; + if (d > bestD) { + bestD = d; + best = z; + } + } + starts.push(best); + } + return starts.map((z) => { + const vis = new Uint8Array(board.cells); + for (const c of board.zoneCells[z]) reveal(board, vis, c, 1); + return vis; + }); + } + + /** Nebel des Krieges: Umgebung der neuen Figur und des gehaltenen Lands von p aufdecken. */ + private revealAround(p: number, piece: number): void { + const vis = this.visible[p]; + reveal(this.board, vis, piece, SIGHT_PIECE); + for (let i = 0; i < this.board.cells; i++) if (this.holder(i) === p) reveal(this.board, vis, i, SIGHT_LAND); + } + + /** Sieht Spieler p dieses Feld? (ohne Nebel: immer) */ + isVisible(i: number, p = this.current): boolean { + return !this.fog || this.visible[p][i] === 1; + } + + /** + * Wer hält Feld i? Spieler (0-basiert), -1 = niemand, -2 = umkämpft (Gleichstand der Stärke). + * Solo und „classic“: beanspruchtes Land gehört dem Spieler, dessen ZoI es abdeckt – und das Feld einer Figur + * ihrem Besitzer (Issue #57: die eigene Figur steht nicht in ihrer eigenen ZoI, zählte also nicht mit; unter + * „realms“ wirkt sie mit ihrer Stärke ohnehin auch auf ihr Feld). + */ + holder(i: number): number { + if (!this.usesStrength) { + if (this.piece[i]) return this.owner[i] > 0 ? this.owner[i] - 1 : 0; + if (this.players === 1) return this.zoiCount[i] > 0 ? 0 : -1; + let who = -1; + for (let p = 0; p < this.players; p++) { + if (!this.zoiOwn[p][i]) continue; + if (who !== -1) return -2; + who = p; + } + return who; + } + let best = 0; + let who = -1; + for (let p = 0; p < this.strength.length; p++) { + const v = this.strength[p][i]; + if (v > best) { + best = v; + who = p === this.players ? BANDIT : p; + } else if (v === best && v > 0) who = -2; + } + return who; + } + + /** Stärkster Gegner von p auf Feld i (Regeln „realms“) */ + private enemyStrength(i: number, p: number): number { + let max = 0; + for (let q = 0; q < this.strength.length; q++) if (q !== p && this.strength[q][i] > max) max = this.strength[q][i]; + return max; + } + + /** + * Herrscher je Landstrich (-1 = niemand). 1v1: wer dort mehr Felder hält; Solo: wer mindestens + * die Hälfte der bebaubaren Felder (ohne Seen) hält. + */ + rulers(): number[] { + const board = this.board; + const out: number[] = []; + const held = new Array(this.players); + for (let z = 0; z < board.zoneCount; z++) { + held.fill(0); + let land = 0; + for (const c of board.zoneCells[z]) { + if (board.lake[c]) continue; + land++; + const h = this.holder(c); + if (h >= 0) held[h]++; + } + if (this.players === 1) { + out.push(held[0] * 2 >= land && held[0] > 0 ? 0 : -1); + continue; + } + const best = Math.max(...held); + const leaders = held.flatMap((n, p) => (n === best ? [p] : [])); + out.push(best > 0 && leaders.length === 1 ? leaders[0] : -1); + } + return out; + } + + /** Anzahl beherrschter Landstriche je Spieler */ + regionCounts(): number[] { + const counts = new Array(this.players).fill(0); + for (const r of this.rulers()) if (r >= 0) counts[r]++; + return counts; + } + + /** Gehaltene Felder je Spieler (1 = hält), gecacht je Spielstand – fürs Andocken unter „realms“ */ + private heldMask(p: number): Uint8Array { + const cached = this.heldCache.get(p); + if (cached && cached.version === this.version) return cached.bits; + const bits = new Uint8Array(this.board.cells); + for (let i = 0; i < bits.length; i++) bits[i] = this.strength[p][i] > this.enemyStrength(i, p) ? 1 : 0; + this.heldCache.set(p, { version: this.version, bits }); + return bits; + } + + /** Energie des Spielers am Zug (Solo: die Punkte). */ + get score(): number { + return this.scores[this.current]; + } + + set score(value: number) { + this.scores[this.current] = value; + } + + /** Felder, an die Spieler p andocken darf (Solo: alle beanspruchten; „realms“: die er hält). */ + ownZoI(p: number): Uint8Array { + if (this.usesStrength) return this.heldMask(p); + return this.players === 1 ? this.zoiCount : this.zoiOwn[p]; + } + + /** Rätsel: ab jetzt nur diese Einheiten (null = alle). Vorgegebene Steine dürfen andere sein. */ + setAllowedUnits(units: readonly UnitType[] | null): void { + this.allowedUnits = units; + this.version++; + } + + /** Rätsel: ab jetzt noch höchstens n Züge (null hebt die Grenze auf). */ + setMovesLeft(n: number | null): void { + this.moveLimit = n === null ? null : this.log.length + n; + this.version++; + } + + /** Verbleibende Züge bis zur Grenze (null = unbegrenzt). */ + get movesLeft(): number | null { + return this.moveLimit === null ? null : Math.max(0, this.moveLimit - this.log.length); + } + + /** Undo-Grenze ändern (z. B. lockerer Modus = Infinity). Älterer Verlauf wird bei Verkleinerung verworfen. */ + setUndoLimit(limit: number): void { + this.undoLimit = limit; + while (this.history.length > limit) this.history.shift(); + } + + get trait() { + return this.board.trait; + } + + unitAt(i: number): UnitType | null { + return UNIT_BY_CODE[this.piece[i]]; + } + + count(unit: UnitType, player = this.current): number { + return this.counts[player * UNIT_SLOTS + UNIT_CODE[unit]]; + } + + cost(unit: UnitType): number { + const base = getPieceCost(unit, this.trait); + return this.boons ? Math.max(0, base - (this.boons.discount?.[unit] ?? 0)) : base; + } + + inZoI(i: number): boolean { + return this.zoiCount[i] > 0; + } + + /** Wie viele eigene Steine (des Spielers) stehen in dieser Zone? */ + piecesInZone(zone: number, player = this.current): number { + const mark = player + 1; + let n = 0; + for (const c of this.board.zoneCells[zone]) if (this.owner[c] === mark) n++; + return n; + } + + // ========================================== + // REGELN + // ========================================== + + /** Darf Spieler `player` (Standard: wer am Zug ist) diese Einheit hier setzen? */ + canPlace(unit: UnitType, i: number, player = this.current): PlacementCheck { + const def = UNIT_DEFS[unit]; + const board = this.board; + const cost = this.cost(unit); + const fail = (error: PlacementError): PlacementCheck => ({ ok: false, error, cost }); + + if (this.moveLimit !== null && this.log.length >= this.moveLimit) return fail('MOVE_LIMIT'); + if (this.allowedUnits && !this.allowedUnits.includes(unit)) return fail('UNIT_NOT_ALLOWED'); + if (def.maxCount !== undefined && this.count(unit, player) >= def.maxCount) return fail('UNIT_LIMIT'); + if (cost > 0 && this.scores[player] < cost) return fail('NOT_AFFORDABLE'); + if (this.piece[i] || this.camps.includes(i)) return fail('OCCUPIED'); + if (board.lake[i]) return fail('LAKE'); + if (this.fog && !this.visible[player][i]) return fail('HIDDEN'); + if (def.forbiddenBiomes.includes(board.biomeAt(i))) return fail('BIOME_FORBIDDEN'); + + const zoneLimit = WORLD_RULES[this.trait].maxPerZone?.[unit]; + if (zoneLimit !== undefined) { + const code = UNIT_CODE[unit]; + const mark = player + 1; + let inZone = 0; + for (const c of board.zoneCells[board.zone[i]]) if (this.piece[c] === code && this.owner[c] === mark) inZone++; + if (inZone >= zoneLimit) return fail('ZONE_UNIT_LIMIT'); + } + + if (unit !== 'HIVEMOTHER' && this.count('HIVEMOTHER', player) === 0) return fail('NO_MOTHER'); + if (this.usesStrength) { + // Eigenes Land bleibt frei (Abstand); fremdes oder umkämpftes Land nur, wenn man danach stärker ist + const mine = this.strength[player][i]; + const theirs = this.enemyStrength(i, player); + if (mine > theirs) return fail('OWN_LAND'); + if (theirs > 0 && mine + def.strength <= theirs) return fail('TOO_WEAK'); + } else if (this.zoiCount[i]) { + // Gebaut wird nie in einer ZoI – egal wessen + return fail('IN_ZOI'); + } + + const dock = this.dockingError(unit, i, this.ownZoI(player)); + return dock ? fail(dock) : { ok: true, cost }; + } + + /** Andocken nur an die eigene ZoI (`own`); im Solo ist das die gesamte ZoI. */ + private dockingError(unit: UnitType, i: number, own: Uint8Array): PlacementError | null { + const def = UNIT_DEFS[unit]; + if (def.docking === 'none') return null; + + const zone = this.board.zone; + const z = zone[i]; + let touchesOtherZone = false; + + if (def.docking === 'adjacent') { + for (const n of this.board.ring[i]) { + if (!own[n]) continue; + if (zone[n] === z) return null; + touchesOtherZone = true; + } + } else { + for (const p of this.board.zoiOf(unit, i)) { + if (own[p]) return null; + for (const n of this.board.orth[p]) { + if (!own[n]) continue; + if (!def.dockSameZone || zone[n] === z) return null; + touchesOtherZone = true; + } + } + } + + return touchesOtherZone && def.dockSameZone ? 'DOCK_OTHER_ZONE' : 'NOT_DOCKED'; + } + + /** Bitkarte (1 = gültig) aller Felder, auf die diese Einheit gerade gesetzt werden darf. */ + legalMoves(unit: UnitType, player = this.current): Uint8Array { + const key = player * UNIT_SLOTS + UNIT_CODE[unit]; + const cached = this.legalCache.get(key); + if (cached && cached.version === this.version) return cached.bits; + const bits = new Uint8Array(this.board.cells); + for (let i = 0; i < bits.length; i++) bits[i] = this.canPlace(unit, i, player).ok ? 1 : 0; + this.legalCache.set(key, { version: this.version, bits }); + return bits; + } + + legalMoveCount(unit: UnitType, player = this.current): number { + return this.legalMoves(unit, player).reduce((a, b) => a + b, 0); + } + + /** Hat dieser Spieler noch irgendeinen gültigen Zug? */ + canMove(player = this.current): boolean { + return UNIT_TYPES.some((u) => this.legalMoves(u, player).indexOf(1) !== -1); + } + + /** + * Keine Einheit kann mehr irgendwo gesetzt werden. Im 1v1 ist nach jedem Zug derjenige dran, + * der noch ziehen kann (wer nicht kann, passt) – also ist die Partie vorbei, wenn er es nicht kann. + */ + isGameOver(): boolean { + return !this.canMove() || this.decidedEarly(); + } + + /** + * 1v1 vorzeitig entschieden (Issue #2): Wer nicht mehr ziehen kann, kann nicht mehr aufholen – sein Land kann nur + * noch schrumpfen, sein Gold bleibt. Liegt der andere schon vorn, endet die Partie sofort (statt dass er allein + * weiterziehen muss). Liegt der Festsitzende vorn oder gleichauf, zieht der andere weiter und kann überholen. + */ + decidedEarly(): boolean { + if (this.players < 2) return false; + const leader = this.winner(); + if (leader === null) return false; + for (let p = 0; p < this.players; p++) if (p !== leader && this.canMove(p)) return false; + return true; + } + + /** + * Sieger im 1v1 oder null bei Gleichstand. „classic“: höchstes Gold; „realms“: meiste Landstriche, + * bei Gleichstand entscheidet das Gold. + */ + winner(): number | null { + const lead = (values: number[], among: number[]) => { + const best = Math.max(...among.map((p) => values[p])); + return among.filter((p) => values[p] === best); + }; + let leaders = [...this.scores.keys()]; + if (this.contested) leaders = lead(this.regionCounts(), leaders); + leaders = lead(this.scores, leaders); + return leaders.length === 1 ? leaders[0] : null; + } + + // ========================================== + // ZÜGE + // ========================================== + + place(unit: UnitType, i: number): PlacementResult { + const check = this.canPlace(unit, i); + if (!check.ok) return check; + + const p = this.current; + const move: Move = { unit, cell: i }; + // Ohne Undo (Vorschau, Simulation, Server-Prüfung) keinen Schnappschuss anlegen – spart viel Kopieren + if (this.undoLimit > 0) { + this.history.push(this.snapshot(move)); + if (this.history.length > this.undoLimit) this.history.shift(); + } + this.log.push(move); + + const code = UNIT_CODE[unit]; + const scores = this.scores; + scores[p] = Math.max(0, scores[p] - check.cost); + this.piece[i] = code; + this.owner[i] = p + 1; + this.counts[p * UNIT_SLOTS + code]++; + const own = this.players > 1 ? this.zoiOwn[p] : null; + const zoi = this.board.zoiOf(unit, i); + // Land vorher merken (nur „realms“): wer hielt die betroffenen Felder, wer herrschte wo? + const before = this.usesStrength ? [i, ...zoi].map((c) => this.holder(c)) : null; + const rulersBefore = this.usesStrength ? this.rulers() : null; + for (const c of zoi) { + this.zoiCount[c]++; + this.zoiMask[c] |= 1 << code; + if (own) own[c]++; + } + if (this.usesStrength) { + const add = UNIT_DEFS[unit].strength; + const mine = this.strength[p]; + mine[i] += add; + for (const c of zoi) mine[c] += add; + } + + const result = this.resolveYield(unit, i, p); + scores[p] = Math.max(0, scores[p] + result.points); + this.version++; + + // Räuberlager ausheben: wer auf dem Lager stärker ist als die Räuber, nimmt es samt Beute + const fallen: number[] = []; + let loot = 0; + if (this.bandits) { + const bandit = this.strength[this.players]; + this.camps.forEach((c, k) => { + if (!this.campAlive[k] || this.strength[p][c] <= bandit[c]) return; + this.campAlive[k] = 0; + bandit[c] -= CAMP_STRENGTH; + for (const n of this.board.ring[c]) if (!this.board.lake[n]) bandit[n] -= CAMP_RING_STRENGTH; + fallen.push(c); + loot += CAMP_LOOT + (this.boons?.loot ?? 0); + }); + scores[p] += loot; + } + if (this.fog) this.revealAround(p, i); + + let conquest = NO_CONQUEST; + if (before && rulersBefore) { + const cells = [i, ...zoi]; + const rulersAfter = this.rulers(); + const wasOther = (h: number) => h === BANDIT || (h >= 0 && h !== p); + conquest = { + attack: before[0] !== -1 && before[0] !== p, + captured: cells.filter((c, k) => wasOther(before[k]) && this.holder(c) === p), + won: rulersAfter.flatMap((r, z) => (r === p && rulersBefore[z] !== p ? [z] : [])), + taken: rulersAfter.flatMap((r, z) => + rulersBefore[z] >= 0 && rulersBefore[z] !== p && r !== rulersBefore[z] ? [z] : [] + ), + camps: fallen, + loot, + }; + } + if (this.players > 1) this.current = this.nextPlayer(); + + return { ok: true, move, player: p, cost: check.cost, yield: result, score: scores[p], conquest }; + } + + /** Wer nach dem letzten Zug dran ist: planmäßig – außer der kann nicht mehr ziehen, dann passt er. */ + private nextPlayer(): number { + const planned = scheduledPlayer(this.log.length, this.players, this.opening); + if (this.canMove(planned)) return planned; + for (let k = 1; k < this.players; k++) { + const other = (planned + k) % this.players; + if (this.canMove(other)) return other; + } + return planned; // niemand kann mehr ziehen → Partie vorbei + } + + /** + * Simuliert einen Zug auf einer Kopie: gleiche Regeln wie place(), aber Spielstand, + * Undo-Verlauf und Caches bleiben unberührt (für die Platzier-Vorschau). + */ + preview(unit: UnitType, i: number): PlacementPreview { + const check = this.canPlace(unit, i); + if (!check.ok) return check; + const result = this.clone().place(unit, i); + if (!result.ok) return result; // kann nicht passieren (gleiche Prüfung), hält aber die Typen ehrlich + return { + ok: true, + cost: result.cost, + yield: result.yield, + net: result.score - this.score, + zoi: this.board.zoiOf(unit, i), + conquest: result.conquest, + }; + } + + /** Unabhängige Kopie des aktuellen Spielstands (ohne Undo-Verlauf) – für Vorschau und Simulation. */ + clone(): Game { + const copy = new Game(this.board, { + undoLimit: 0, + players: this.players as 1 | 2, + opening: this.opening, + allowedUnits: this.allowedUnits ?? undefined, + rules: this.rules, + bandits: this.bandits, + fog: this.fog, + boons: this.boons ?? undefined, + }); + copy.moveLimit = this.moveLimit; + copy.piece = this.piece.slice(); + copy.owner = this.owner.slice(); + copy.mined = this.mined.slice(); + copy.zoiCount = this.zoiCount.slice(); + copy.zoiMask = this.zoiMask.slice(); + copy.zoiOwn = this.zoiOwn.map((z) => z.slice()); + copy.strength = this.strength.map((z) => z.slice()); + copy.campAlive = this.campAlive.slice(); + copy.visible = this.visible.map((v) => v.slice()); + copy.counts = this.counts.slice(); + copy.scores = this.scores.slice(); + copy.current = this.current; + copy.log = this.log.slice(); + return copy; + } + + /** + * Würde dieser (gültige) Zug die Partie sofort beenden, weil danach kein Zug mehr möglich ist? + * Eigene Methode statt Teil von preview(): kostet 4 × 81 Regelprüfungen und wird nur für die + * Anzeige gebraucht – Bots und Simulationen rufen preview() sehr oft auf. + */ + endsGameAfter(unit: UnitType, i: number): boolean { + const sim = this.clone(); + return sim.place(unit, i).ok && sim.isGameOver(); + } + + /** Felder, auf denen der Spieler gerade irgendeine Einheit setzen darf. */ + playableCellCount(player = this.current): number { + const any = new Uint8Array(this.board.cells); + for (const unit of UNIT_TYPES) { + const bits = this.legalMoves(unit, player); + for (let i = 0; i < any.length; i++) any[i] |= bits[i]; + } + return any.reduce((a, b) => a + b, 0); + } + + /** Ertrag für Spieler p; Tandem-Abbau zählt nur seine eigenen Steine in der Zone. */ + private resolveYield(unit: UnitType, i: number, p: number): PlacementYield { + const board = this.board; + const z = board.zone[i]; + const multiplier = UNIT_DEFS[unit].yieldMultiplier; + + if (board.biomeAt(i) === 'PLAINS') { + const basePoints = board.settlementPoints(z); + return { + kind: 'settled', + points: (basePoints + (this.boons?.pasture ?? 0)) * multiplier, + basePoints, + lakeCount: board.zoneLakeCount[z], + multiplier, + }; + } + + if (WORLD_RULES[this.trait].tandemMining) { + if (this.piecesInZone(z, p) >= 2) { + const mined: MinedItem[] = []; + let points = 0; + for (const c of board.zoneCells[z]) { + const res = board.resource[c]; + const occupant = this.unitAt(c); + if (occupant && res && !this.mined[c] && this.owner[c] === p + 1) { + const m = UNIT_DEFS[occupant].yieldMultiplier; + const pts = (RESOURCE_DEFS[res].points + (this.boons?.treasure ?? 0)) * m; + this.mined[c] = 1; + points += pts; + mined.push({ cell: c, resource: res, points: pts, multiplier: m }); + } + } + return points > 0 ? { kind: 'tandemMined', points, mined } : { kind: 'tandemNothing', points: 0 }; + } + const res = board.resource[i]; + if (res) { + return { + kind: 'tandemPending', + points: 0, + resource: res, + potentialPoints: (RESOURCE_DEFS[res].points + (this.boons?.treasure ?? 0)) * multiplier, + }; + } + return { kind: 'none', points: 0 }; + } + + const res = board.resource[i]; + if (res && !this.mined[i]) { + const pts = (RESOURCE_DEFS[res].points + (this.boons?.treasure ?? 0)) * multiplier; + this.mined[i] = 1; + return { kind: 'mined', points: pts, item: { cell: i, resource: res, points: pts, multiplier } }; + } + return { kind: 'none', points: 0 }; + } + + // ========================================== + // UNDO + // ========================================== + + /** Züge, die zur aktuellen Stellung geführt haben (ohne zurückgenommene). */ + get moves(): readonly Move[] { + return this.log; + } + + get canUndo(): boolean { + return this.history.length > 0; + } + + /** Letzter rückgängig machbarer Zug. */ + get lastMove(): Move | null { + return this.history.at(-1)?.move ?? null; + } + + /** Wer den letzten rückgängig machbaren Zug gespielt hat (oder null). */ + get lastMover(): number | null { + return this.history.at(-1)?.player ?? null; + } + + /** Macht den letzten Zug rückgängig und liefert ihn zurück (oder null). */ + undo(): Move | null { + const snap = this.history.pop(); + if (!snap) return null; + this.log.pop(); + this.piece = snap.piece; + this.owner = snap.owner; + this.mined = snap.mined; + this.zoiCount = snap.zoiCount; + this.zoiMask = snap.zoiMask; + this.zoiOwn = snap.zoiOwn; + this.strength = snap.strength; + this.campAlive = snap.campAlive; + this.visible = snap.visible; + this.counts = snap.counts; + this.scores = snap.scores; + this.current = snap.player; + this.version++; + return snap.move; + } + + private snapshot(move: Move): Snapshot { + return { + move, + player: this.current, + piece: this.piece.slice(), + owner: this.owner.slice(), + mined: this.mined.slice(), + zoiCount: this.zoiCount.slice(), + zoiMask: this.zoiMask.slice(), + zoiOwn: this.zoiOwn.map((z) => z.slice()), + strength: this.strength.map((z) => z.slice()), + campAlive: this.campAlive.slice(), + visible: this.visible.map((v) => v.slice()), + counts: this.counts.slice(), + scores: this.scores.slice(), + }; + } +} + +/** Felder im Schachbrett-Abstand r um c als sichtbar markieren */ +function reveal(board: Board, vis: Uint8Array, c: number, r: number): void { + const cx = board.x(c); + const cy = board.y(c); + const last = board.size - 1; + for (let y = Math.max(0, cy - r); y <= Math.min(last, cy + r); y++) + for (let x = Math.max(0, cx - r); x <= Math.min(last, cx + r); x++) vis[y * board.size + x] = 1; +} + +/** Fisher–Yates mit geseedetem Zufall */ +function shuffle(list: T[], rng: () => number): void { + for (let i = list.length - 1; i > 0; i--) { + const j = Math.floor(rng() * (i + 1)); + [list[i], list[j]] = [list[j], list[i]]; + } +} diff --git a/src/engine/core/GameModes.ts b/src/engine/core/GameModes.ts new file mode 100644 index 0000000..3eed512 --- /dev/null +++ b/src/engine/core/GameModes.ts @@ -0,0 +1,20 @@ +/** + * GameModes.ts + * Spielmodi der Missions-Auswahl. Titel, Beschreibungen und Tags stehen in src/i18n (mode..*). + */ + +export const GAME_MODE_IDS = ['colony', 'pvp_war', 'survival_puzzle'] as const; +export type GameModeId = (typeof GAME_MODE_IDS)[number]; + +export interface GameModeInfo { + id: GameModeId; + /** Icon-ID im SVG-Sprite (index.html) */ + icon: string; + playable: boolean; +} + +export const GAME_MODES: Record = { + colony: { id: 'colony', icon: 'i-crown', playable: true }, + pvp_war: { id: 'pvp_war', icon: 'i-swords', playable: true }, + survival_puzzle: { id: 'survival_puzzle', icon: 'i-puzzle', playable: false }, +}; diff --git a/src/engine/core/MapGenerator.ts b/src/engine/core/MapGenerator.ts new file mode 100644 index 0000000..32c2e9d --- /dev/null +++ b/src/engine/core/MapGenerator.ts @@ -0,0 +1,281 @@ +/** + * MapGenerator.ts + * Erzeugt aus einem Seed deterministisch eine komplette Map: + * Zonen (Region-Growing) → Biome → Seen → Rohstoffe → Welt-Regel. + */ + +import { Board, SIZE, geometry, zoneCountFor } from './Board'; +import type { Geometry } from './Board'; +import type { Resource } from './Board'; +import { BIOMES } from './Biomes'; +import type { BiomeType } from './Biomes'; +import { WORLD_TRAIT_IDS } from './WorldRules'; +import type { WorldTrait } from './WorldRules'; +import { mulberry32, randInt, shuffle } from './rng'; +import type { Rng } from './rng'; + +/** + * Version des Generators. Map-Codes sind nur innerhalb derselben Version reproduzierbar: + * Jede Änderung, die bei gleichem Seed eine andere Map erzeugt, MUSS diese Zahl erhöhen + * (und den Snapshot-Test in tests/MapGenerator.test.ts bewusst aktualisieren). + */ +export const GENERATOR_VERSION = 1; + +export interface MapOptions { + /** Welt-Regel erzwingen (sonst aus dem Seed abgeleitet) */ + trait?: WorldTrait; + /** Kantenlänge (9, 12 oder 15; Standard 9). 9 × 9 bleibt Feld für Feld wie bisher (Generator-Vertrag). */ + size?: number; + zoneCount?: number; + minZoneSize?: number; + maxZoneSize?: number; + maxAttempts?: number; + /** Wahrscheinlichkeit für Seen in Ebenen-Zonen (Standard 0.75) */ + lakeChance?: number; + /** Seen in jeder Ebenen-Zone erzwingen (für Tests) */ + guaranteeLakes?: boolean; +} + +/** + * Welt-Regel, die generateBoard() für diesen Seed würfelt (ohne die ganze Map zu erzeugen). + * Muss dieselbe erste Zufallszahl verwenden wie generateBoard – abgesichert per Test. + */ +export function rolledTrait(seed: number): WorldTrait { + return WORLD_TRAIT_IDS[randInt(mulberry32(seed), WORLD_TRAIT_IDS.length)]; +} + +export function generateBoard(seed: number, options: MapOptions = {}): Board { + const rng = mulberry32(seed); + const g = geometry(options.size ?? SIZE); + const zoneCount = options.zoneCount ?? zoneCountFor(g.size); + + // Welt-Regel zuerst ziehen, damit sie nicht von der Anzahl der Zonen-Versuche abhängt + const rolledTrait = WORLD_TRAIT_IDS[randInt(rng, WORLD_TRAIT_IDS.length)]; + const trait = options.trait ?? rolledTrait; + + const zone = generateZones(rng, g, zoneCount, options); + const zoneBiome = assignBiomes(rng, g, zone, zoneCount); + const zoneCells: number[][] = Array.from({ length: zoneCount }, () => []); + for (let i = 0; i < g.cells; i++) zoneCells[zone[i]].push(i); + + const lakes = placeLakes(rng, zoneCells, zoneBiome, options); + const resources = placeResources(rng, zone, zoneBiome, zoneCells); + + return new Board({ seed, trait, zone, zoneBiome, lakes, resources, size: g.size }); +} + +// ========================================== +// ZONEN +// ========================================== + +function generateZones(rng: Rng, g: Geometry, zoneCount: number, options: MapOptions): Uint8Array { + const min = options.minZoneSize ?? 7; + const max = options.maxZoneSize ?? 11; + const attempts = options.maxAttempts ?? 500; + + for (let a = 0; a < attempts; a++) { + const layout = growZones(rng, g, zoneCount, min, max); + if (layout) return layout; + } + return growZones(rng, g, zoneCount, 4, 16) ?? standardBlocks(g); +} + +/** Ein Versuch Region-Growing: immer die kleinste Zone wächst in ein freies Nachbarfeld. */ +function growZones(rng: Rng, g: Geometry, zoneCount: number, min: number, max: number): Uint8Array | null { + const owner = new Int8Array(g.cells).fill(-1); + const zones: number[][] = []; + + for (const seedCell of distributeSeeds(rng, g, zoneCount)) { + owner[seedCell] = zones.length; + zones.push([seedCell]); + } + + let unassigned = g.cells - zoneCount; + while (unassigned > 0) { + let target = -1; + let targetNeighbors: number[] = []; + for (let z = 0; z < zones.length; z++) { + if (target >= 0 && zones[z].length >= zones[target].length) continue; + const free: number[] = []; + for (const c of zones[z]) { + for (const n of g.orth[c]) { + if (owner[n] === -1 && !free.includes(n)) free.push(n); + } + } + if (free.length > 0) { + target = z; + targetNeighbors = free; + } + } + if (target < 0) return null; + + const chosen = targetNeighbors[randInt(rng, targetNeighbors.length)]; + owner[chosen] = target; + zones[target].push(chosen); + unassigned--; + } + + const sizes = zones.map((z) => z.length); + if (Math.min(...sizes) < min || Math.max(...sizes) > max) return null; + return Uint8Array.from(owner); +} + +/** + * Ein Startfeld pro 3 × 3-Block, wenn die Zonen genau ein Blockraster füllen (9 Zonen auf 9 × 9, 16 auf 12 × 12, + * 25 auf 15 × 15) – sonst zufällig verteilt. Für 9 × 9 dieselbe Zufallsfolge wie bisher. + */ +function distributeSeeds(rng: Rng, g: Geometry, count: number): number[] { + const seeds: number[] = []; + const blocks = g.size / 3; + if (count === blocks * blocks) { + for (let by = 0; by < blocks; by++) { + for (let bx = 0; bx < blocks; bx++) { + // Reihenfolge der Zufallszahlen wie bisher: erst Spalte, dann Zeile (sonst andere 9 × 9-Karten) + const x = bx * 3 + randInt(rng, 3); + const y = by * 3 + randInt(rng, 3); + seeds.push(y * g.size + x); + } + } + return seeds; + } + while (seeds.length < count) { + const i = randInt(rng, g.cells); + if (!seeds.includes(i)) seeds.push(i); + } + return seeds; +} + +function standardBlocks(g: Geometry): Uint8Array { + const zone = new Uint8Array(g.cells); + const blocks = Math.max(1, Math.floor(g.size / 3)); + for (let i = 0; i < g.cells; i++) { + const x = i % g.size; + const y = (i / g.size) | 0; + zone[i] = Math.min(blocks - 1, Math.floor(y / 3)) * blocks + Math.min(blocks - 1, Math.floor(x / 3)); + } + return zone; +} + +// ========================================== +// BIOME +// ========================================== + +/** Zonen-Nachbarschaft: zwei Zonen grenzen an, wenn sie sich eine orthogonale Kante teilen. */ +export function zoneAdjacency(zone: ArrayLike, zoneCount: number, g: Geometry = geometry(SIZE)): Set[] { + const adj = Array.from({ length: zoneCount }, () => new Set()); + for (let i = 0; i < g.cells; i++) { + for (const n of g.orth[i]) { + if (zone[n] !== zone[i]) adj[zone[i]].add(zone[n]); + } + } + return adj; +} + +/** + * Je 9 Lande: 2 Gebirge (nie benachbart), 1 Sumpf, Rest Ebene. Größere Karten bekommen entsprechend mehr. + */ +function assignBiomes(rng: Rng, g: Geometry, zone: Uint8Array, zoneCount: number): BiomeType[] { + const adj = zoneAdjacency(zone, zoneCount, g); + const order = shuffle( + rng, + Array.from({ length: zoneCount }, (_, z) => z) + ); + if (zoneCount !== 9) return assignScaledBiomes(adj, order, zoneCount); + + let mountains: number[] = []; + for (const a of order) { + const b = order.find((c) => c !== a && !adj[a].has(c)); + if (b !== undefined) { + mountains = [a, b]; + break; + } + } + if (mountains.length < 2) mountains = order.slice(0, 2); + + const swamp = order.find((z) => !mountains.includes(z)); + return Array.from({ length: zoneCount }, (_, z) => + mountains.includes(z) ? 'MOUNTAIN' : z === swamp ? 'SWAMP' : 'PLAINS' + ); +} + +/** Größere Karten: Gebirge und Sümpfe im selben Verhältnis wie auf 9 × 9, Gebirge möglichst nicht benachbart. */ +function assignScaledBiomes(adj: Set[], order: number[], zoneCount: number): BiomeType[] { + const mountainCount = Math.round((zoneCount * 2) / 9); + const swampCount = Math.round(zoneCount / 9); + const mountains: number[] = []; + for (const z of order) { + if (mountains.length >= mountainCount) break; + if (mountains.every((m) => !adj[m].has(z))) mountains.push(z); + } + for (const z of order) { + if (mountains.length >= mountainCount) break; + if (!mountains.includes(z)) mountains.push(z); + } + const swamps = order.filter((z) => !mountains.includes(z)).slice(0, swampCount); + return Array.from({ length: zoneCount }, (_, z) => + mountains.includes(z) ? 'MOUNTAIN' : swamps.includes(z) ? 'SWAMP' : 'PLAINS' + ); +} + +// ========================================== +// SEEN & ROHSTOFFE +// ========================================== + +/** + * Seen nur in Ebenen: Zonen bis 10 Felder max. 1 See, bis 20 Felder max. 2 Seen. + */ +function placeLakes(rng: Rng, zoneCells: number[][], zoneBiome: BiomeType[], options: MapOptions): number[] { + const chance = options.lakeChance ?? 0.75; + const lakes: number[] = []; + + zoneCells.forEach((cells, z) => { + if (zoneBiome[z] !== 'PLAINS') return; + const shuffled = shuffle(rng, cells); + let count = 0; + if (cells.length <= 10) { + count = options.guaranteeLakes || rng() < chance ? 1 : 0; + } else if (cells.length <= 20) { + count = options.guaranteeLakes ? 2 : rng() < chance ? (rng() < 0.5 ? 2 : 1) : 0; + } + lakes.push(...shuffled.slice(0, count)); + }); + + return lakes; +} + +/** + * 2x Erz im Gebirge (möglichst in verschiedenen Zonen), 1x Ur-Biomasse im Sumpf. + * Ebenen bekommen keine Rohstoffe. + */ +function placeResources( + rng: Rng, + zone: Uint8Array, + zoneBiome: BiomeType[], + zoneCells: number[][] +): [number, Resource][] { + const resources = new Map(); + + // Ein Erz je Gebirgs-Land (9 × 9: 2), möglichst in verschiedenen Landen + const oreTarget = zoneBiome.filter((b) => b === 'MOUNTAIN').length; + const mountainCells = shuffle(rng, zoneCells.filter((_, z) => BIOMES[zoneBiome[z]].resource === 'ORE').flat()); + const usedZones = new Set(); + for (const i of mountainCells) { + if (resources.size >= oreTarget) break; + if (!usedZones.has(zone[i])) { + resources.set(i, 'ORE'); + usedZones.add(zone[i]); + } + } + for (const i of mountainCells) { + if (resources.size >= oreTarget) break; + if (!resources.has(i)) resources.set(i, 'ORE'); + } + + // Kräuter: eins je Sumpf-Land (9 × 9: genau eins – dieselbe Zufallsfolge wie bisher) + zoneCells.forEach((cells, z) => { + if (BIOMES[zoneBiome[z]].resource !== 'BIOMASS' || cells.length === 0) return; + resources.set(cells[randInt(rng, cells.length)], 'BIOMASS'); + }); + + return [...resources]; +} diff --git a/src/engine/core/Types.ts b/src/engine/core/Types.ts new file mode 100644 index 0000000..2f6b413 --- /dev/null +++ b/src/engine/core/Types.ts @@ -0,0 +1,25 @@ +/** + * Types.ts + * Gemeinsame Typen und Rohstoff-Definitionen für Hivedoku. + */ + +import type { Resource } from './Board'; + +export interface ResourceDef { + points: number; + color: string; +} + +export const RESOURCE_DEFS: Record = { + ORE: { points: 20, color: '#f59e0b' }, + BIOMASS: { points: 20, color: '#a855f7' }, +}; + +/** + * Aktive Anwendungsansicht: + * - 'landing': Startseite / Login + * - 'hub': Spielmodus-Auswahl / Missions-Zentrale + * - 'game': Das eigentliche Spielfeld + * - 'popular-maps': Beliebteste Maps & Top 5 Ranglisten + */ +export type ActiveView = 'landing' | 'hub' | 'game' | 'popular-maps'; diff --git a/src/engine/core/WorldRules.ts b/src/engine/core/WorldRules.ts new file mode 100644 index 0000000..d92ac61 --- /dev/null +++ b/src/engine/core/WorldRules.ts @@ -0,0 +1,124 @@ +/** + * WorldRules.ts + * Verwaltung aller Map-Regeln (Welt-Eigenschaften) und Baukosten für Hivedoku: + * - Regel 1: Tandem-Abbau (Min. 2 Steine pro Zone für Rohstoff-Punkte) + * - Regel 2: Worker-Spezialisierung (Max. 1 Hiveworker pro Zone) + * - Regel 3: Drohnen-Inflation (Doppelte Baukosten für Drohnenarbeiter: 20 Pkt) + * - Regel 4: Scout-Teuerung (Hivescouts kosten das doppelte: 30 Pkt) + * - Regel 5: Drohnen-Subvention (Drohnen haben Baukosten von 5 Pkt) + */ + +import type { UnitType } from './units'; + +/** + * Verfügbare Welt-Regel IDs: + */ +export type WorldTrait = + | 'TANDEM_MINING' + | 'SINGLE_WORKER_PER_ROOM' + | 'DOUBLE_DRONE_COST' + | 'DOUBLE_SCOUT_COST' + | 'CHEAP_DRONES' + /** Kein Gesetz: alle Grundkosten, keine Sonderregel – nur wählbar („Eigenes Spiel“), nie ausgewürfelt */ + | 'NONE'; + +/** + * Definition einer Map-Regel (Name & Beschreibung stehen in src/i18n unter trait..*). + */ +export interface WorldRuleDef { + id: WorldTrait; + ruleNumber: number; + /** Icon-ID im SVG-Sprite (index.html), z. B. 'i-users' */ + icon: string; + color: string; + /** Abweichende Baukosten unter dieser Regel */ + costs?: Partial>; + /** Maximale Anzahl einer Einheit pro Zone */ + maxPerZone?: Partial>; + /** Rohstoffe werden erst ab 2 Steinen in der Zone abgebaut */ + tandemMining?: boolean; +} + +/** + * Alle 5 Hivedoku Map-Regeln im Detail: + */ +export const WORLD_RULES: Record = { + TANDEM_MINING: { + id: 'TANDEM_MINING', + ruleNumber: 1, + icon: 'i-users', + color: '#f59e0b', + tandemMining: true, + }, + SINGLE_WORKER_PER_ROOM: { + id: 'SINGLE_WORKER_PER_ROOM', + ruleNumber: 2, + icon: 'u-HIVEWORKER', + color: '#f2c14e', + maxPerZone: { HIVEWORKER: 1 }, + }, + DOUBLE_DRONE_COST: { + id: 'DOUBLE_DRONE_COST', + ruleNumber: 3, + icon: 'u-DRONE', + color: '#8a2f2f', + costs: { DRONE: 20 }, + }, + DOUBLE_SCOUT_COST: { + id: 'DOUBLE_SCOUT_COST', + ruleNumber: 4, + icon: 'u-HIVESCOUT', + color: '#b5563f', + costs: { HIVESCOUT: 30 }, + }, + CHEAP_DRONES: { + id: 'CHEAP_DRONES', + ruleNumber: 5, + icon: 'i-energy', + color: '#6aa84f', + costs: { DRONE: 5 }, + }, + NONE: { + id: 'NONE', + ruleNumber: 0, + icon: 'i-crown', + color: '#c9b38a', + }, +}; + +/** + * Regel-IDs, aus denen der Generator würfelt – feste Reihenfolge (wichtig für deterministische Seeds). + * „Kein Gesetz“ steht absichtlich NICHT hier: sonst bekäme jede bestehende Karte ein anderes Gesetz. + */ +export const WORLD_TRAIT_IDS: readonly WorldTrait[] = [ + 'TANDEM_MINING', + 'SINGLE_WORKER_PER_ROOM', + 'DOUBLE_DRONE_COST', + 'DOUBLE_SCOUT_COST', + 'CHEAP_DRONES', +]; + +/** Wählbare Gesetze (Zufall würfelt nur aus WORLD_TRAIT_IDS): alle fünf plus „Kein Gesetz“ */ +export const SELECTABLE_TRAIT_IDS: readonly WorldTrait[] = [...WORLD_TRAIT_IDS, 'NONE']; + +/** + * Grundkosten der Spielsteine (in Schwarm-Energie): + * - HIVEMOTHER: 0 (Kostenlos) + * - HIVEWORKER: 0 (Kostenlos) + * - DRONE: 10 Grundpunkte + * - HIVESCOUT: 15 Grundpunkte + */ +export const BASE_PIECE_COSTS: Record = { + HIVEMOTHER: 0, + HIVEWORKER: 0, + DRONE: 10, + HIVESCOUT: 15, +}; + +/** + * Berechnet die tatsächlichen Baukosten für einen Spielstein unter Berücksichtigung + * der aktuell aktiven Welt-Eigenschaft. + */ +export function getPieceCost(piece: UnitType, trait: WorldTrait): number { + return WORLD_RULES[trait].costs?.[piece] ?? BASE_PIECE_COSTS[piece]; +} diff --git a/src/engine/core/Zones.ts b/src/engine/core/Zones.ts new file mode 100644 index 0000000..401b445 --- /dev/null +++ b/src/engine/core/Zones.ts @@ -0,0 +1,25 @@ +/** + * Zones.ts + * Signalfarben der 9 Waben-Zonen (Index = Zonen-Nummer der Map). Namen stehen in src/i18n (zone.). + */ + +/** 9 kontrastreiche Sci-Fi-Neon-Töne, eine pro Zone. */ +export const ZONE_COLOR_PALETTE: readonly string[] = [ + '#f59e0b', // Bernstein + '#f43f5e', // Bio-Rot + '#10b981', // Smaragd + '#06b6d4', // Cyan + '#3b82f6', // Plasma-Blau + '#a855f7', // Violett + '#ec4899', // Magenta + '#64748b', // Schiefer + '#f97316', // Orange + // Größere Karten (16 / 25 Lande): erdigere Töne dazu + '#84cc16', // Moos + '#b45309', // Rost + '#0ea5e9', // Himmel + '#d946ef', // Fuchsie + '#eab308', // Senf + '#14b8a6', // Türkis + '#e11d48', // Rubin +]; diff --git a/src/engine/core/achievements.ts b/src/engine/core/achievements.ts new file mode 100644 index 0000000..32a7c5f --- /dev/null +++ b/src/engine/core/achievements.ts @@ -0,0 +1,185 @@ +/** + * achievements.ts + * Erfolge (Issue #67, Phase 3): Gründe, anders zu spielen – nicht nur mehr. Jeder Erfolg bringt Ansehen. + * - Aus geprüften Sternen abgeleitet (Summen, Gesetze, Größen, Feldzüge, Rätsel) – nie wieder verloren, weil beste + * Sterne nur wachsen. + * - Taten einer Partie (gameFeats): prüft der Server beim Nachspielen einer freien Karte und merkt sie sich. + * - Online-Siege zählt der Server; Tagesserie und Siege gegen den Computer kennt nur der Browser (gemeldet wie bei + * den Profilbildern). + * Schwellen aus Bot-Simulationen (120 Karten, „smart“): alle Lande schafft er fast immer, 150 Gold in 10 % der + * Partien, alle Räuberlager in 23 %, drei Sterne ohne Läufer nie, drei Sterne nie mit weniger als 28 Figuren. + */ + +import { sizeFromMapId } from './Board'; +import type { CampaignId } from './campaign'; +import { + ALL_CAMPAIGN_LEVELS, + CAMPAIGN_LEVELS_BY_ID, + campaignStarsKey, + puzzleIdOfKey, + puzzleStarsKey, +} from './progress'; +import { SELECTABLE_TRAIT_IDS } from './WorldRules'; + +import { FEATS } from './feats'; +import type { Feat } from './feats'; + +export { FEATS, FRUGAL_PIECES, gameFeats } from './feats'; +export type { Feat } from './feats'; + +export type AchievementCheck = + | { kind: 'stars'; count: number } + | { kind: 'threeStars'; count: number } + | { kind: 'lawMaster' } + | { kind: 'sizeThree'; size: 12 | 15 } + | { kind: 'variantThree'; variant: 'bandits' | 'fog' } + | { kind: 'campaign'; campaign: CampaignId; stars: 1 | 3 } + | { kind: 'puzzles'; count: number; chapters?: boolean } + | { kind: 'feat'; feat: Feat } + | { kind: 'duelWins'; count: number } + /** Eroberungszug: so viele Stufen in einem Zug geschafft (bester Zug) */ + | { kind: 'conquest'; stages: number } + /** Wochen-Herausforderungen mit mindestens stars Sternen */ + | { kind: 'weekly'; count: number; stars: 1 | 3 } + /** Aufgabe, die nur der Browser kennt – gemeldet als Profilbild-ID (Tagesserie, Sieg gegen den Computer) */ + | { kind: 'reported'; avatar: string }; + +export interface AchievementDef { + id: string; + check: AchievementCheck; + renown: number; +} + +/** Rätsel der Kapitel (IDs „c…“; ohne den Vorrat der Tagesrätsel) – fest, damit die Rätsel-Daten nicht mitgeladen werden */ +export const PUZZLES_TOTAL = 24; + +export const ACHIEVEMENTS: readonly AchievementDef[] = [ + { id: 'firstStar', check: { kind: 'stars', count: 1 }, renown: 20 }, + { id: 'stars100', check: { kind: 'stars', count: 100 }, renown: 100 }, + { id: 'stars500', check: { kind: 'stars', count: 500 }, renown: 250 }, + { id: 'stars1000', check: { kind: 'stars', count: 1000 }, renown: 500 }, + { id: 'threeStars10', check: { kind: 'threeStars', count: 10 }, renown: 100 }, + { id: 'threeStars50', check: { kind: 'threeStars', count: 50 }, renown: 300 }, + { id: 'lawMaster', check: { kind: 'lawMaster' }, renown: 250 }, + { id: 'mediumThree', check: { kind: 'sizeThree', size: 12 }, renown: 100 }, + { id: 'bigThree', check: { kind: 'sizeThree', size: 15 }, renown: 150 }, + { id: 'banditThree', check: { kind: 'variantThree', variant: 'bandits' }, renown: 100 }, + { id: 'fogThree', check: { kind: 'variantThree', variant: 'fog' }, renown: 100 }, + { id: 'kingdom', check: { kind: 'campaign', campaign: 'kingdom', stars: 1 }, renown: 150 }, + { id: 'orc', check: { kind: 'campaign', campaign: 'orc', stars: 1 }, renown: 150 }, + { id: 'pirate', check: { kind: 'campaign', campaign: 'pirate', stars: 1 }, renown: 150 }, + { id: 'kingdomPerfect', check: { kind: 'campaign', campaign: 'kingdom', stars: 3 }, renown: 300 }, + { id: 'orcPerfect', check: { kind: 'campaign', campaign: 'orc', stars: 3 }, renown: 300 }, + { id: 'piratePerfect', check: { kind: 'campaign', campaign: 'pirate', stars: 3 }, renown: 300 }, + { id: 'puzzles25', check: { kind: 'puzzles', count: 25 }, renown: 100 }, + { id: 'puzzlesAll', check: { kind: 'puzzles', count: PUZZLES_TOTAL, chapters: true }, renown: 400 }, + { id: 'weekly3', check: { kind: 'weekly', count: 1, stars: 3 }, renown: 100 }, + { id: 'weekly10', check: { kind: 'weekly', count: 10, stars: 1 }, renown: 300 }, + { id: 'conquest5', check: { kind: 'conquest', stages: 5 }, renown: 100 }, + { id: 'conquest10', check: { kind: 'conquest', stages: 10 }, renown: 250 }, + { id: 'conquest20', check: { kind: 'conquest', stages: 20 }, renown: 500 }, + { id: 'allLands', check: { kind: 'feat', feat: 'allLands' }, renown: 50 }, + { id: 'gold150', check: { kind: 'feat', feat: 'gold150' }, renown: 100 }, + { id: 'gold200', check: { kind: 'feat', feat: 'gold200' }, renown: 250 }, + { id: 'raider', check: { kind: 'feat', feat: 'raider' }, renown: 150 }, + { id: 'noKnights', check: { kind: 'feat', feat: 'noKnights' }, renown: 150 }, + { id: 'noBishops', check: { kind: 'feat', feat: 'noBishops' }, renown: 300 }, + { id: 'frugal', check: { kind: 'feat', feat: 'frugal' }, renown: 300 }, + { id: 'duelWin1', check: { kind: 'duelWins', count: 1 }, renown: 50 }, + { id: 'duelWin10', check: { kind: 'duelWins', count: 10 }, renown: 150 }, + { id: 'duelWin50', check: { kind: 'duelWins', count: 50 }, renown: 400 }, + { id: 'beatGreedy', check: { kind: 'reported', avatar: 'orc' }, renown: 50 }, + { id: 'beatHard', check: { kind: 'reported', avatar: 'minotaur' }, renown: 150 }, + { id: 'streak7', check: { kind: 'reported', avatar: 'yeti' }, renown: 100 }, + { id: 'streak30', check: { kind: 'reported', avatar: 'mammoth' }, renown: 300 }, +]; + +export const ACHIEVEMENT_IDS: readonly string[] = ACHIEVEMENTS.map((a) => a.id); + +/** Was zählt: beste Sterne je Fortschritts-Schlüssel, gemerkte Taten, Online-Siege, gemeldete Browser-Aufgaben */ +export interface AchievementFacts { + progress: Record; + feats: readonly string[]; + duelWins: number; + reported: readonly string[]; + /** Geschaffte Stufen im besten Eroberungszug (fehlt = 0) */ + conquest?: number; +} + +/** Freie Karte aus dem Schlüssel „Code|Gesetz|Version[|b][|f]“ */ +function mapKey(key: string): { mapId: string; trait: string; bandits: boolean; fog: boolean } | null { + if (key.startsWith('campaign:') || puzzleIdOfKey(key) !== null) return null; + const parts = key.split('|'); + if (parts.length < 3) return null; + return { mapId: parts[0], trait: parts[1], bandits: parts.includes('b'), fog: parts.includes('f') }; +} + +/** Sterne einer Feldzug-Station; Rätsel-Stationen zählen die Sterne des Rätsels */ +function stationStars(progress: Record, level: (typeof ALL_CAMPAIGN_LEVELS)[number]): number { + return progress[level.puzzle ? puzzleStarsKey(level.puzzle) : campaignStarsKey(level.id)] ?? 0; +} + +/** Stand eines Erfolgs: erreicht? und – bei Zählern – wie weit (have / need) */ +export function achievementStatus(def: AchievementDef, facts: AchievementFacts): { have: number; need: number } { + const { progress } = facts; + const entries = Object.entries(progress); + const c = def.check; + switch (c.kind) { + case 'stars': + return { have: entries.reduce((n, [, s]) => n + s, 0), need: c.count }; + case 'threeStars': + return { have: entries.filter(([k, s]) => s === 3 && puzzleIdOfKey(k) === null).length, need: c.count }; + case 'lawMaster': { + const laws = new Set(entries.filter(([k, s]) => s === 3 && mapKey(k)).map(([k]) => mapKey(k)!.trait)); + return { have: SELECTABLE_TRAIT_IDS.filter((t) => laws.has(t)).length, need: SELECTABLE_TRAIT_IDS.length }; + } + case 'sizeThree': + return { + have: entries.some(([k, s]) => s === 3 && mapKey(k) && sizeFromMapId(mapKey(k)!.mapId) === c.size) ? 1 : 0, + need: 1, + }; + case 'variantThree': + return { have: entries.some(([k, s]) => s === 3 && mapKey(k)?.[c.variant]) ? 1 : 0, need: 1 }; + case 'campaign': { + const levels = CAMPAIGN_LEVELS_BY_ID[c.campaign]; + return { have: levels.filter((l) => stationStars(progress, l) >= c.stars).length, need: levels.length }; + } + case 'puzzles': + return { + have: entries.filter( + ([k, s]) => s > 0 && (c.chapters ? puzzleIdOfKey(k)?.startsWith('c') : puzzleIdOfKey(k) !== null) + ).length, + need: c.count, + }; + case 'feat': + return { have: facts.feats.includes(c.feat) ? 1 : 0, need: 1 }; + case 'duelWins': + return { have: facts.duelWins, need: c.count }; + case 'conquest': + return { have: facts.conquest ?? 0, need: c.stages }; + case 'weekly': + return { have: entries.filter(([k, s]) => k.startsWith('weekly:') && s >= c.stars).length, need: c.count }; + case 'reported': + return { have: facts.reported.includes(c.avatar) ? 1 : 0, need: 1 }; + } +} + +/** Fortschritt aller Erfolge (je ID: have / need) – für die Balken im Konto-Fenster */ +export function achievementProgress(facts: AchievementFacts): Record { + return Object.fromEntries(ACHIEVEMENTS.map((a) => [a.id, achievementStatus(a, facts)])); +} + +/** Erreichte Erfolge (IDs in der Reihenfolge der Liste) */ +export function earnedAchievements(facts: AchievementFacts): string[] { + return ACHIEVEMENTS.filter((a) => { + const { have, need } = achievementStatus(a, facts); + return have >= need; + }).map((a) => a.id); +} + +/** Ansehen aus erreichten Erfolgen */ +export function achievementRenown(ids: readonly string[]): number { + return ACHIEVEMENTS.reduce((n, a) => n + (ids.includes(a.id) ? a.renown : 0), 0); +} + +export const isFeat = (value: unknown): value is Feat => FEATS.includes(value as Feat); diff --git a/src/engine/core/avatars.ts b/src/engine/core/avatars.ts new file mode 100644 index 0000000..970cae4 --- /dev/null +++ b/src/engine/core/avatars.ts @@ -0,0 +1,167 @@ +/** + * avatars.ts + * Profilbilder und Teamfarben (Issue #48). Bilder werden durch Aufgaben freigeschaltet; Browser und Server nutzen + * dieselbe Liste. Was der Server aus eigenen Daten prüfen kann (Sterne, Online-Duelle), rechnet er selbst nach; + * was nur der Browser weiß (Siege gegen den Computer, Tagesserie), meldet der Browser – rein kosmetisch, Schummeln + * bringt nur ein Bild. Gäste haben kein eigenes Bild, sie zeigen DEFAULT_AVATAR. + * Die Bilder schneidet scripts/make-avatars.py aus dem Sprite-Pack (public/sprites/avatars/.png). + */ + +import { CAMPAIGN_LEVELS, campaignStarsKey, puzzleIdOfKey, puzzleStarsKey } from './progress'; +import { sizeFromMapId } from './Board'; + +/** Teamfarben = Farben, in denen der Sprite-Pack alle Figuren hat */ +export const TEAM_COLORS = ['red', 'cyan', 'lime', 'purple'] as const; +export type TeamColor = (typeof TEAM_COLORS)[number]; + +/** Anzeige-Farbe je Teamfarbe (Ringe, Vorschau, Duell-Leiste) */ +export const TEAM_HEX: Record = { + red: '#e05a4f', + cyan: '#3fc1c9', + lime: '#8bc34a', + purple: '#a46bd6', +}; + +export const DEFAULT_COLOR: TeamColor = 'red'; +export const DEFAULT_AVATAR = 'farmer'; +/** Bild des Computer-Gegners (nicht wählbar) */ +export const COMPUTER_AVATAR = 'computer'; + +export type Unlock = + | { kind: 'free' } + | { kind: 'account' } + | { kind: 'campaignFirst' } + | { kind: 'chapter'; chapter: number } + | { kind: 'campaignDone' } + | { kind: 'campaignStars'; stars: number } + | { kind: 'puzzlesSolved'; count: number } + | { kind: 'puzzlesPerfect'; count: number } + | { kind: 'streak'; days: number } + | { kind: 'botWin'; level: 'greedy' | 'hard'; size?: number } + | { kind: 'duelWins'; count: number; rated: boolean } + | { kind: 'bigMapPerfect' }; + +export interface AvatarDef { + id: string; + unlock: Unlock; +} + +export const AVATARS: readonly AvatarDef[] = [ + { id: 'farmer', unlock: { kind: 'free' } }, + { id: 'spearman', unlock: { kind: 'account' } }, + { id: 'sheep', unlock: { kind: 'account' } }, + { id: 'chicken', unlock: { kind: 'campaignFirst' } }, + { id: 'pig', unlock: { kind: 'chapter', chapter: 3 } }, + { id: 'boar', unlock: { kind: 'chapter', chapter: 6 } }, + { id: 'arthax', unlock: { kind: 'campaignDone' } }, + { id: 'katan', unlock: { kind: 'campaignStars', stars: 60 } }, + { id: 'black-dragon', unlock: { kind: 'campaignStars', stars: CAMPAIGN_LEVELS.length * 3 } }, + { id: 'slime', unlock: { kind: 'puzzlesSolved', count: 10 } }, + { id: 'king-slime', unlock: { kind: 'puzzlesPerfect', count: 30 } }, + { id: 'yeti', unlock: { kind: 'streak', days: 7 } }, + { id: 'mammoth', unlock: { kind: 'streak', days: 30 } }, + { id: 'orc', unlock: { kind: 'botWin', level: 'greedy' } }, + { id: 'minotaur', unlock: { kind: 'botWin', level: 'hard' } }, + { id: 'red-dragon', unlock: { kind: 'botWin', level: 'hard', size: 15 } }, + { id: 'pirate', unlock: { kind: 'duelWins', count: 1, rated: false } }, + { id: 'pirate-captain', unlock: { kind: 'duelWins', count: 10, rated: true } }, + { id: 'necromancer', unlock: { kind: 'bigMapPerfect' } }, +]; + +export const AVATAR_IDS: readonly string[] = AVATARS.map((a) => a.id); + +export const isTeamColor = (value: unknown): value is TeamColor => TEAM_COLORS.includes(value as TeamColor); +export const isAvatarId = (value: unknown): value is string => AVATAR_IDS.includes(value as string); + +/** Nur der Browser kennt das (Siege gegen den Computer, Tagesserie) – der Server vertraut der Meldung */ +export const reportedByBrowser = (unlock: Unlock): boolean => unlock.kind === 'botWin' || unlock.kind === 'streak'; + +/** Was der Server selbst zählen kann */ +export interface ServerFacts { + /** beste Sterne je Fortschritts-Schlüssel (nur Einträge mit Sternen) */ + progress: Record; + /** gewonnene Online-Duelle insgesamt / davon gewertet */ + duelWins: number; + ratedWins: number; +} + +/** Sterne einer Feldzug-Station; Rätsel-Stationen zählen die Sterne des Rätsels */ +function stationStars(progress: Record, level: (typeof CAMPAIGN_LEVELS)[number]): number { + return progress[level.puzzle ? puzzleStarsKey(level.puzzle) : campaignStarsKey(level.id)] ?? 0; +} + +/** Ist die Aufgabe nach den Daten des Servers erfüllt? Browser-Aufgaben → false (kommen als Meldung). */ +export function meetsServerUnlock(unlock: Unlock, facts: ServerFacts): boolean { + const { progress } = facts; + const stations = (chapter?: number) => CAMPAIGN_LEVELS.filter((l) => chapter === undefined || l.chapter === chapter); + const puzzleStars = () => + Object.entries(progress) + .filter(([key]) => puzzleIdOfKey(key) !== null) + .map(([, stars]) => stars); + switch (unlock.kind) { + case 'free': + case 'account': + return true; + case 'campaignFirst': + return stationStars(progress, CAMPAIGN_LEVELS[0]) > 0; + case 'chapter': + return stations(unlock.chapter).every((l) => stationStars(progress, l) > 0); + case 'campaignDone': + return stations().every((l) => stationStars(progress, l) > 0); + case 'campaignStars': + return stations().reduce((n, l) => n + stationStars(progress, l), 0) >= unlock.stars; + case 'puzzlesSolved': + return puzzleStars().filter((s) => s > 0).length >= unlock.count; + case 'puzzlesPerfect': + return puzzleStars().filter((s) => s >= 3).length >= unlock.count; + case 'duelWins': + return (unlock.rated ? facts.ratedWins : facts.duelWins) >= unlock.count; + case 'bigMapPerfect': + // Schlüssel freier Karten beginnen mit dem Map-Code (12×12 endet auf -M, 15×15 auf -L) + return Object.entries(progress).some( + ([key, stars]) => stars >= 3 && !key.includes(':') && sizeFromMapId(key.split('|')[0]) > 9 + ); + case 'botWin': + case 'streak': + return false; + } +} + +// ========================================== +// Völker (Issue #52): nur Aussehen der Figuren, gleiche Regeln +// ========================================== + +export const FACTIONS = ['kingdom', 'orc', 'pirate'] as const; +export type Faction = (typeof FACTIONS)[number]; +export const DEFAULT_FACTION: Faction = 'kingdom'; + +/** Königreich für alle; Orks und Piraten nach dem ersten Feldzug */ +export const FACTION_UNLOCK: Record = { + kingdom: { kind: 'free' }, + orc: { kind: 'campaignDone' }, + pirate: { kind: 'campaignDone' }, +}; + +export const isFaction = (value: unknown): value is Faction => FACTIONS.includes(value as Faction); + +export function unlockedFactions(facts: ServerFacts): Faction[] { + return FACTIONS.filter((f) => meetsServerUnlock(FACTION_UNLOCK[f], facts)); +} + +/** Volk des Computers bzw. des zweiten Spielers am Gerät: immer ein anderes als das eigene */ +export const otherFaction = (mine: Faction): Faction => (mine === 'kingdom' ? 'orc' : 'kingdom'); + +/** Freigeschaltete Bilder eines Kontos: eigene Daten des Servers plus gemeldete Browser-Aufgaben */ +export function unlockedAvatars(facts: ServerFacts, reported: readonly string[]): string[] { + return AVATARS.filter( + (a) => meetsServerUnlock(a.unlock, facts) || (reportedByBrowser(a.unlock) && reported.includes(a.id)) + ).map((a) => a.id); +} + +/** + * Farben beider Seiten im Duell: wie gewählt; gleiche Farbe → der zweite Platz bekommt die nächste Farbe. + */ +export function matchColors(first: TeamColor, second: TeamColor): [TeamColor, TeamColor] { + if (first !== second) return [first, second]; + return [first, TEAM_COLORS[(TEAM_COLORS.indexOf(second) + 1) % TEAM_COLORS.length]]; +} diff --git a/src/engine/core/bots.ts b/src/engine/core/bots.ts new file mode 100644 index 0000000..d547e54 --- /dev/null +++ b/src/engine/core/bots.ts @@ -0,0 +1,193 @@ +/** + * bots.ts + * Einfache, geseedete Spieler für Simulationen (Balance-Skript, Kalibrierung der Ränge per Test). + * - random: beliebiger gültiger Zug (nur Simulationen) + * - easy: Stufe „Leicht“ – meist der greedy-Zug, sonst ein beliebiger (Issue #33: random wirkte sinnlos) + * - greedy: höchster Saldo (Ertrag − Kosten) laut preview() + * - smart: unter den 5 besten Salden der Zug, der danach die meisten bebaubaren Felder übrig lässt + * (im 1v1: eigene minus gegnerische bebaubare Felder – Abschneiden lohnt sich); Sterne-Ziele (goals.ts) + * - hard: Stufe „Schwer“ im 1v1 (Issue #51): spielt die besten Kandidaten ein Stück weit mit greedy auf beiden + * Seiten durch und nimmt den mit der besten Stellung danach – smart schlug greedy nur ~58 : 42 + * Alle Bots ziehen für den Spieler, der gerade am Zug ist (Solo und 1v1). + * Regeln „realms“ im 1v1: greedy und smart bewerten nicht den Gold-Saldo, sondern die Stellung danach + * (Landstriche, gehaltene Felder, Gold – siehe realmValue); gemessen mit scripts/realms-sim.mjs. + */ + +import type { Game } from './Game'; +import { randInt } from './rng'; +import type { Rng } from './rng'; +import { UNIT_TYPES } from './units'; +import type { UnitType } from './units'; + +export interface BotMove { + unit: UnitType; + cell: number; + net: number; +} + +export type BotName = 'random' | 'easy' | 'greedy' | 'smart' | 'hard'; +/** Wählbare Stufen gegen den Computer (Leicht, Mittel, Schwer) */ +export type BotLevel = Exclude; + +/** Anteil guter Züge der Stufe „Leicht“ (gemessen mit scripts/match-balance.mjs, siehe Plan #33) */ +export const EASY_GOOD_MOVES = 0.5; + +/** Alle gültigen Züge mit ihrem Saldo. */ +export function allMoves(game: Game): BotMove[] { + const out: BotMove[] = []; + for (const unit of UNIT_TYPES) { + const legal = game.legalMoves(unit); + for (let cell = 0; cell < legal.length; cell++) { + if (!legal[cell]) continue; + const preview = game.preview(unit, cell); + if (preview.ok) out.push({ unit, cell, net: preview.net }); + } + } + return out; +} + +/** Stellung aus Sicht von p unter „realms“: Landstriche zählen viel, gehaltene Felder und Gold weniger. */ +export function realmValue(game: Game, p: number): number { + const regions = game.regionCounts(); + const held = new Array(game.players).fill(0); + for (let i = 0; i < game.board.zone.length; i++) { + const h = game.holder(i); + if (h >= 0) held[h]++; + } + let value = 0; + for (let q = 0; q < game.players; q++) { + const sign = q === p ? 1 : -1 / (game.players - 1); + value += sign * (12 * regions[q] + held[q] + 0.25 * game.scores[q]); + } + return value; +} + +/** Alle gültigen Züge, bewertet mit realmValue nach dem Zug (net = Wert). */ +function realmMoves(game: Game): BotMove[] { + const me = game.current; + const out: BotMove[] = []; + for (const unit of UNIT_TYPES) { + const legal = game.legalMoves(unit); + for (let cell = 0; cell < legal.length; cell++) { + if (!legal[cell]) continue; + const sim = game.clone(); + sim.place(unit, cell); + out.push({ unit, cell, net: realmValue(sim, me) }); + } + } + return out; +} + +/** Bewertete Züge je nach Regelwerk: „realms“ im 1v1 nach Stellung, sonst nach Gold-Saldo. */ +function scoredMoves(game: Game): BotMove[] { + return game.contested ? realmMoves(game) : allMoves(game); +} + +/** Wert der Stellung für p: realms nach realmValue, sonst Gold-Abstand; beendete Partien zählen Sieg/Niederlage voll. */ +function positionValue(game: Game, p: number): number { + if (game.isGameOver()) { + const w = game.winner(); + if (w !== null) return w === p ? 1e6 : -1e6; + } + if (game.contested) return realmValue(game, p); + let value = 0; + for (let q = 0; q < game.players; q++) value += q === p ? game.scores[q] : -game.scores[q] / (game.players - 1); + return value; +} + +/** + * Stufe „Schwer“: so viele Kandidaten, jeder so viele Züge weit durchgespielt (gemessen mit scripts/match-balance.mjs, + * siehe Plan #51). Mehr Kandidaten oder ganze Partien bringen kaum mehr, kosten auf 15×15 aber Sekunden am Handy. + */ +export const HARD_CANDIDATES = 4; +export const HARD_DEPTH = 10; + +/** Bebaubare Felder nach dem Zug: Solo die eigenen, im 1v1 eigene minus die aller Gegner. */ +function mobilityAfter(game: Game, move: BotMove): number { + const me = game.current; + const sim = game.clone(); + sim.place(move.unit, move.cell); + let value = sim.playableCellCount(me); + for (let p = 0; p < game.players; p++) if (p !== me) value -= sim.playableCellCount(p); + return value; +} + +export const BOTS: Record BotMove> = { + random(game, rng) { + const moves = allMoves(game); + return moves[randInt(rng, moves.length)]; + }, + easy(game, rng) { + if (rng() < EASY_GOOD_MOVES) return BOTS.greedy(game, rng); + const moves = allMoves(game); + return moves[randInt(rng, moves.length)]; + }, + greedy(game, rng) { + const moves = scoredMoves(game); + const best = Math.max(...moves.map((m) => m.net)); + const top = moves.filter((m) => m.net === best); + return top[randInt(rng, top.length)]; + }, + smart(game, rng) { + const candidates = scoredMoves(game) + .sort((a, b) => b.net - a.net) + .slice(0, 5); + let best = candidates[0]; + let bestValue = -Infinity; + for (const move of candidates) { + // Saldo zählt voll, jedes übrig bleibende Feld als kleine Option auf künftige Punkte + const value = move.net + 0.6 * mobilityAfter(game, move) + rng() * 0.01; + if (value > bestValue) { + bestValue = value; + best = move; + } + } + return best; + }, + hard(game, rng) { + if (game.players < 2) return BOTS.smart(game, rng); + const me = game.current; + const candidates = scoredMoves(game) + .sort((x, y) => y.net - x.net) + .slice(0, HARD_CANDIDATES); + let best = candidates[0]; + let bestValue = -Infinity; + for (const move of candidates) { + // Zug setzen, dann beide Seiten greedy weiterspielen lassen und die Stellung danach bewerten + const sim = game.clone(); + sim.place(move.unit, move.cell); + for (let ply = 0; ply < HARD_DEPTH && !sim.isGameOver(); ply++) { + const reply = BOTS.greedy(sim, rng); + sim.place(reply.unit, reply.cell); + } + const value = positionValue(sim, me); + if (value > bestValue) { + bestValue = value; + best = move; + } + } + return best; + }, +}; + +/** Spielt eine Partie bis zum Ende; liefert Endpunkte, Zugzahl und gebaute Einheiten. */ +export function playOut(game: Game, bot: BotName, rng: Rng) { + let moves = 0; + while (!game.isGameOver()) { + const move = BOTS[bot](game, rng); + game.place(move.unit, move.cell); + moves++; + } + return { score: game.score, moves, units: Object.fromEntries(UNIT_TYPES.map((u) => [u, game.count(u)])) }; +} + +/** 1v1 Bot gegen Bot bis zum Ende (Messung des Anzugsvorteils); bots[p] spielt für Spieler p. */ +export function playMatch(game: Game, bots: readonly BotName[], rng: Rng) { + let moves = 0; + while (!game.isGameOver()) { + const move = BOTS[bots[game.current]](game, rng); + game.place(move.unit, move.cell); + moves++; + } + return { scores: game.scores.slice(), winner: game.winner(), moves }; +} diff --git a/src/engine/core/campaign.ts b/src/engine/core/campaign.ts new file mode 100644 index 0000000..3710401 --- /dev/null +++ b/src/engine/core/campaign.ts @@ -0,0 +1,287 @@ +/** + * campaign.ts + * Feldzug durch die neun Lande (Phase 11e): neun Kapitel, je eines nach einem Landstrich benannt, jedes führt + * genau eine Neuerung ein (Figur, Gelände oder Gesetz). Je Kapitel drei Stationen = feste Karten mit Sterne-Zielen. + * Die Kapitel stehen hier; welche Karten die Stationen sind, wählt scripts/make-campaign.mjs (→ src/campaign/). + * + * Weitere Feldzüge je Volk (Issue #53): Orkhorde und Piraten, je neun Kapitel mit vier Stationen, schwerer als der + * erste – alle Figuren von Anfang an, später Räuber und Nebel. Freigeschaltet mit dem ersten Feldzug (wie die Völker). + */ + +import type { Faction } from './avatars'; +import type { UnitType } from './units'; +import type { WorldTrait } from './WorldRules'; + +export type ChapterFocus = 'basic' | 'ore' | 'lakes' | 'herbs'; + +export interface CampaignChapter { + /** 1–9 */ + id: number; + /** Landstrich, nach dem das Kapitel heißt (Index der Namen zone.0 … zone.8) */ + zone: number; + /** Erlaubte Figuren (null = alle) */ + units: UnitType[] | null; + /** Gesetz je Station (3 Einträge) */ + traits: readonly WorldTrait[]; + /** Worauf der Karten-Generator achtet (z. B. viele Seen) */ + focus: ChapterFocus; + /** Vierte Station: ein Rätsel (ID aus src/puzzles) – erst ab Kapitel 5, wenn alle Figuren bekannt sind */ + puzzle?: string; + /** Räuberlager bzw. Nebel auf allen Karten des Kapitels */ + bandits?: boolean; + fog?: boolean; +} + +const BASIC: UnitType[] = ['HIVEMOTHER', 'HIVEWORKER']; +const WITH_KNIGHTS: UnitType[] = ['HIVEMOTHER', 'HIVEWORKER', 'DRONE']; +/** Gesetz, das ohne Läufer nichts ändert – für Kapitel, in denen es noch keine Läufer gibt */ +const QUIET: WorldTrait = 'DOUBLE_SCOUT_COST'; + +export const CHAPTERS: readonly CampaignChapter[] = [ + { id: 1, zone: 0, units: BASIC, traits: [QUIET, QUIET, QUIET], focus: 'basic' }, + { id: 2, zone: 1, units: WITH_KNIGHTS, traits: ['CHEAP_DRONES', 'CHEAP_DRONES', QUIET], focus: 'basic' }, + { id: 3, zone: 4, units: WITH_KNIGHTS, traits: [QUIET, QUIET, QUIET], focus: 'ore' }, + { id: 4, zone: 5, units: WITH_KNIGHTS, traits: [QUIET, QUIET, QUIET], focus: 'lakes' }, + { + id: 5, + zone: 6, + units: null, + traits: ['DOUBLE_DRONE_COST', 'DOUBLE_DRONE_COST', 'CHEAP_DRONES'], + focus: 'basic', + puzzle: 'c1-02', + }, + { + id: 6, + zone: 2, + units: null, + traits: ['CHEAP_DRONES', 'DOUBLE_SCOUT_COST', 'DOUBLE_DRONE_COST'], + focus: 'herbs', + puzzle: 'c1-06', + }, + { + id: 7, + zone: 3, + units: null, + traits: ['DOUBLE_DRONE_COST', 'DOUBLE_SCOUT_COST', 'CHEAP_DRONES'], + focus: 'basic', + puzzle: 'c2-04', + }, + { + id: 8, + zone: 7, + units: null, + traits: ['TANDEM_MINING', 'TANDEM_MINING', 'TANDEM_MINING'], + focus: 'ore', + puzzle: 'c3-05', + }, + { + id: 9, + zone: 8, + units: null, + traits: ['SINGLE_WORKER_PER_ROOM', 'TANDEM_MINING', 'DOUBLE_DRONE_COST'], + focus: 'basic', + puzzle: 'c3-08', + }, +]; + +/** Höchstzahl Sterne einer Station (Anzeige) */ +export const STATIONS_PER_CHAPTER = 3; + +// ========================================== +// Feldzüge der Völker (Issue #53) +// ========================================== + +/** Orkhorde: der Sturm aus dem Steinbruch – früh Räuber (rivalisierende Stämme), später Nebel */ +export const ORC_CHAPTERS: readonly CampaignChapter[] = [ + { + id: 1, + zone: 7, + units: null, + traits: ['CHEAP_DRONES', 'NONE', 'CHEAP_DRONES', 'DOUBLE_SCOUT_COST'], + focus: 'basic', + }, + { + id: 2, + zone: 4, + units: null, + traits: ['TANDEM_MINING', 'NONE', 'DOUBLE_SCOUT_COST', 'TANDEM_MINING'], + focus: 'ore', + }, + { + id: 3, + zone: 1, + units: null, + traits: ['NONE', 'CHEAP_DRONES', 'DOUBLE_DRONE_COST', 'NONE'], + focus: 'basic', + bandits: true, + }, + { + id: 4, + zone: 6, + units: null, + traits: ['DOUBLE_DRONE_COST', 'NONE', 'SINGLE_WORKER_PER_ROOM', 'CHEAP_DRONES'], + focus: 'herbs', + bandits: true, + }, + { + id: 5, + zone: 5, + units: null, + traits: ['NONE', 'DOUBLE_SCOUT_COST', 'TANDEM_MINING', 'DOUBLE_DRONE_COST'], + focus: 'lakes', + bandits: true, + }, + { + id: 6, + zone: 2, + units: null, + traits: ['NONE', 'CHEAP_DRONES', 'DOUBLE_SCOUT_COST', 'SINGLE_WORKER_PER_ROOM'], + focus: 'basic', + fog: true, + }, + { + id: 7, + zone: 3, + units: null, + traits: ['TANDEM_MINING', 'DOUBLE_DRONE_COST', 'NONE', 'TANDEM_MINING'], + focus: 'ore', + bandits: true, + fog: true, + }, + { + id: 8, + zone: 8, + units: null, + traits: ['SINGLE_WORKER_PER_ROOM', 'DOUBLE_SCOUT_COST', 'SINGLE_WORKER_PER_ROOM', 'DOUBLE_DRONE_COST'], + focus: 'basic', + bandits: true, + fog: true, + }, + { + id: 9, + zone: 0, + units: null, + traits: ['DOUBLE_DRONE_COST', 'TANDEM_MINING', 'SINGLE_WORKER_PER_ROOM', 'DOUBLE_SCOUT_COST'], + focus: 'basic', + bandits: true, + fog: true, + }, +]; + +/** Piraten: von den Seen ins Land – viele Seen, früh Nebel über dem Wasser, später Räuber */ +export const PIRATE_CHAPTERS: readonly CampaignChapter[] = [ + { id: 1, zone: 5, units: null, traits: ['NONE', 'CHEAP_DRONES', 'NONE', 'DOUBLE_SCOUT_COST'], focus: 'lakes' }, + { + id: 2, + zone: 3, + units: null, + traits: ['CHEAP_DRONES', 'DOUBLE_SCOUT_COST', 'NONE', 'DOUBLE_DRONE_COST'], + focus: 'basic', + }, + { + id: 3, + zone: 2, + units: null, + traits: ['NONE', 'DOUBLE_SCOUT_COST', 'CHEAP_DRONES', 'NONE'], + focus: 'lakes', + fog: true, + }, + { + id: 4, + zone: 4, + units: null, + traits: ['TANDEM_MINING', 'NONE', 'TANDEM_MINING', 'DOUBLE_DRONE_COST'], + focus: 'ore', + fog: true, + }, + { + id: 5, + zone: 0, + units: null, + traits: ['DOUBLE_DRONE_COST', 'NONE', 'CHEAP_DRONES', 'SINGLE_WORKER_PER_ROOM'], + focus: 'lakes', + bandits: true, + }, + { + id: 6, + zone: 6, + units: null, + traits: ['NONE', 'SINGLE_WORKER_PER_ROOM', 'DOUBLE_SCOUT_COST', 'CHEAP_DRONES'], + focus: 'herbs', + fog: true, + }, + { + id: 7, + zone: 8, + units: null, + traits: ['TANDEM_MINING', 'DOUBLE_SCOUT_COST', 'TANDEM_MINING', 'NONE'], + focus: 'ore', + bandits: true, + }, + { + id: 8, + zone: 1, + units: null, + traits: ['DOUBLE_DRONE_COST', 'SINGLE_WORKER_PER_ROOM', 'NONE', 'DOUBLE_SCOUT_COST'], + focus: 'basic', + bandits: true, + fog: true, + }, + { + id: 9, + zone: 7, + units: null, + traits: ['SINGLE_WORKER_PER_ROOM', 'DOUBLE_DRONE_COST', 'TANDEM_MINING', 'DOUBLE_SCOUT_COST'], + focus: 'lakes', + bandits: true, + fog: true, + }, +]; + +export type CampaignId = 'kingdom' | 'orc' | 'pirate'; + +export interface CampaignDef { + id: CampaignId; + /** Volk, mit dessen Figuren gespielt wird (Königreich: das eigene) */ + faction: Faction | null; + /** Vorsilbe der Stations-IDs (k3-2, o3-2, p3-2) */ + prefix: string; + chapters: readonly CampaignChapter[]; +} + +export const CAMPAIGNS: readonly CampaignDef[] = [ + { id: 'kingdom', faction: null, prefix: 'k', chapters: CHAPTERS }, + { id: 'orc', faction: 'orc', prefix: 'o', chapters: ORC_CHAPTERS }, + { id: 'pirate', faction: 'pirate', prefix: 'p', chapters: PIRATE_CHAPTERS }, +]; + +export const campaignDef = (id: CampaignId | undefined): CampaignDef => + CAMPAIGNS.find((c) => c.id === (id ?? 'kingdom'))!; + +export interface CampaignLevel { + /** Stabile ID (Sterne hängen daran), z. B. „k3-2“ = Kapitel 3, Station 2 */ + id: string; + chapter: number; + /** 1–3 innerhalb des Kapitels */ + station: number; + seed: number; + trait: WorldTrait; + /** Generator-Version, mit der die Karte gewählt wurde (Map-Generator = Vertrag) */ + generatorVersion: number; + /** Rätsel-Station: ID des Rätsels (seed/trait sind dann die des Rätsels); Sterne = Rätsel-Sterne */ + puzzle?: string; + /** Feldzug (fehlt = der erste, Königreich) */ + campaign?: CampaignId; +} + +export function chapterOf(level: CampaignLevel): CampaignChapter { + return campaignDef(level.campaign).chapters[level.chapter - 1]; +} + +/** + * Freigeschaltet: die erste Station immer, jede weitere, sobald die vorige mindestens einen Stern hat. + * stars = beste Sterne je Stations-ID. + */ +export function isUnlocked(levels: readonly CampaignLevel[], index: number, stars: (id: string) => number): boolean { + return index === 0 || stars(levels[index - 1].id) > 0; +} diff --git a/src/engine/core/conquest.ts b/src/engine/core/conquest.ts new file mode 100644 index 0000000..d823e50 --- /dev/null +++ b/src/engine/core/conquest.ts @@ -0,0 +1,184 @@ +/** + * conquest.ts + * Eroberungszug (Issue #67, Phase 6): eine Kette von Karten aus einem Zug-Seed. Jede Karte verlangt Sterne; wer sie + * schafft, wählt eine von drei Gunst-Boni (billigere Figuren, mehr Ertrag, Startgold …), die für den Rest des Zugs + * gelten und sich stapeln. Wer die Sterne verfehlt, beendet den Zug. Die Karten werden schwerer (Räuber, Nebel, + * größere Karten, mehr Sterne). Alles aus dem Seed ableitbar – der Server spielt einen ganzen Zug nach. + * Kurve und Gunst-Werte aus Bot-Simulationen (scripts/conquest-sim.mjs). + */ + +import type { MapSize } from './Board'; +import { computeGoals, starsOf } from './goals'; +import type { MapGoals } from './goals'; +import { Game } from './Game'; +import type { Boons } from './Game'; +import { GENERATOR_VERSION, generateBoard, rolledTrait } from './MapGenerator'; +import { applyMoves } from './replay'; +import { fnv1a, mulberry32, shuffle } from './rng'; +import type { WorldTrait } from './WorldRules'; + +export const BOON_IDS = ['cheapKnights', 'cheapBishops', 'fertile', 'prospector', 'warChest', 'raiders'] as const; +export type BoonId = (typeof BOON_IDS)[number]; + +/** Wirkung je Gunst (einmal gewählt) */ +export const BOON_EFFECTS: Record = { + cheapKnights: { discount: { DRONE: 4 } }, + cheapBishops: { discount: { HIVESCOUT: 5 } }, + fertile: { pasture: 1 }, + prospector: { treasure: 5 }, + warChest: { startGold: 15 }, + raiders: { loot: 15 }, +}; + +export const isBoonId = (value: unknown): value is BoonId => BOON_IDS.includes(value as BoonId); + +/** Gewählte Gunst zusammenrechnen (mehrfach gewählt = mehrfach) */ +export function combineBoons(ids: readonly BoonId[]): Boons { + const out: Required> & { discount: Boons['discount'] } = { + discount: {}, + pasture: 0, + treasure: 0, + startGold: 0, + loot: 0, + }; + for (const id of ids) { + const b = BOON_EFFECTS[id]; + for (const [unit, n] of Object.entries(b.discount ?? {})) { + const d = out.discount!; + d[unit as keyof typeof d] = (d[unit as keyof typeof d] ?? 0) + n; + } + out.pasture += b.pasture ?? 0; + out.treasure += b.treasure ?? 0; + out.startGold += b.startGold ?? 0; + out.loot += b.loot ?? 0; + } + return out; +} + +export interface ConquestStage { + /** Stufe ab 0 */ + index: number; + seed: number; + trait: WorldTrait; + size: MapSize; + bandits: boolean; + fog: boolean; + /** Nötige Sterne, um weiterzuziehen (gemessen an den Zielen der Stufe, stageGoals) */ + required: number; + /** Ziele der Stufe: Gold-Ziel der Karte × goldFactor, Land-Ziel + landBonus */ + goldFactor: number; + landBonus: number; +} + +/** Gold-Ziel wächst je Stufe um diesen Anteil, das Land-Ziel alle LAND_EVERY Stufen um eins */ +export let GOLD_STEP = 0.12; +export let LAND_EVERY = 3; + +/** Nur für die Simulation (scripts/conquest-sim.mjs): Kurve probeweise verstellen */ +export function _tuneCurve(goldStep: number, landEvery: number): void { + GOLD_STEP = goldStep; + LAND_EVERY = landEvery; +} + +/** Ziele einer Stufe aus den Zielen der Karte (ohne Gunst gerechnet – die Gunst macht es leichter) */ +export function stageGoals(stage: ConquestStage, base: MapGoals, zoneCount: number): MapGoals { + return { + gold: Math.ceil(base.gold * stage.goldFactor), + lands: Math.min(zoneCount, base.lands + stage.landBonus), + }; +} + +/** Abschnitte des Zugs: ab welcher Stufe was gilt (letzter passender gewinnt) */ +export const CURVE: readonly { from: number; size: MapSize; bandits: boolean; fog: boolean; required: number }[] = [ + { from: 0, size: 9, bandits: false, fog: false, required: 1 }, + { from: 3, size: 9, bandits: true, fog: false, required: 2 }, + { from: 6, size: 9, bandits: false, fog: true, required: 2 }, + { from: 9, size: 9, bandits: true, fog: true, required: 2 }, + { from: 12, size: 12, bandits: true, fog: false, required: 2 }, + { from: 15, size: 12, bandits: true, fog: true, required: 3 }, +]; + +/** Karte einer Stufe – fest aus Zug-Seed und Stufe */ +export function stageOf(runSeed: number, index: number): ConquestStage { + const seed = fnv1a(`ninelands-conquest-${runSeed}-${index}`); + const part = [...CURVE].reverse().find((c) => index >= c.from)!; + return { + index, + seed, + trait: rolledTrait(seed), + size: part.size, + bandits: part.bandits, + fog: part.fog, + required: part.required, + goldFactor: 1 + GOLD_STEP * index, + landBonus: Math.floor(index / LAND_EVERY), + }; +} + +/** Drei Gunst zur Wahl nach Stufe index (fest aus dem Seed, ohne Doppelte in einer Auswahl) */ +export function boonChoices(runSeed: number, index: number): BoonId[] { + const rng = mulberry32(fnv1a(`ninelands-boons-${runSeed}-${index}`)); + return shuffle(rng, BOON_IDS).slice(0, 3); +} + +/** Größter gültiger Zug-Seed (32 Bit ohne Vorzeichen) */ +export const isRunSeed = (value: unknown): value is number => + typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 0xffffffff; + +/** Längster Zug, den der Server annimmt (Rechenzeit: je Stufe Ziele berechnen) */ +export const MAX_RUN_STAGES = 40; + +/** Eine Stufe eines Zugs: Karte, Spiel mit der bis dahin gewählten Gunst */ +export function stageGame( + runSeed: number, + index: number, + boons: readonly BoonId[] +): { stage: ConquestStage; game: Game } { + const stage = stageOf(runSeed, index); + const board = generateBoard(stage.seed, { trait: stage.trait, size: stage.size }); + const game = new Game(board, { + undoLimit: 1, + bandits: stage.bandits, + fog: stage.fog, + boons: combineBoons(boons.slice(0, index)), + }); + return { stage, game }; +} + +export type ConquestResult = + | { ok: true; cleared: number; gold: number; stages: number } + | { ok: false; error: 'INVALID_RUN' | 'OUTDATED_CLIENT' }; + +/** + * Ganzen Zug prüfen (Server): jede Stufe nachspielen, mit der zuvor gewählten Gunst; jede gewählte Gunst muss + * angeboten worden sein; alle Stufen außer der letzten müssen geschafft sein. Die letzte zählt, wenn geschafft. + * goalsOf liefert die Karten-Ziele (Server: mit Cache). + */ +export function verifyConquest( + run: unknown, + generatorVersion: unknown, + goalsOf: (game: Game) => MapGoals = (game) => computeGoals(game.board, null, { bandits: game.bandits, fog: game.fog }) +): ConquestResult { + if (generatorVersion !== GENERATOR_VERSION) return { ok: false, error: 'OUTDATED_CLIENT' }; + const r = run as { seed?: unknown; stages?: unknown; boons?: unknown } | null; + if (!r || !isRunSeed(r.seed) || !Array.isArray(r.stages) || !Array.isArray(r.boons)) { + return { ok: false, error: 'INVALID_RUN' }; + } + const { seed, stages, boons } = r as { seed: number; stages: unknown[]; boons: unknown[] }; + if (stages.length === 0 || stages.length > MAX_RUN_STAGES || boons.length !== stages.length - 1) { + return { ok: false, error: 'INVALID_RUN' }; + } + if (!boons.every((b, i) => isBoonId(b) && boonChoices(seed, i).includes(b))) + return { ok: false, error: 'INVALID_RUN' }; + let cleared = 0; + let gold = 0; + for (let i = 0; i < stages.length; i++) { + const { stage, game } = stageGame(seed, i, boons as BoonId[]); + if (!applyMoves(game, stages[i]) || game.moves.length === 0) return { ok: false, error: 'INVALID_RUN' }; + const stars = starsOf(game, stageGoals(stage, goalsOf(game), game.board.zoneCount)).count; + gold += game.score; + if (stars >= stage.required) cleared++; + else if (i < stages.length - 1) return { ok: false, error: 'INVALID_RUN' }; + } + return { ok: true, cleared, gold, stages: stages.length }; +} diff --git a/src/engine/core/cosmetics.ts b/src/engine/core/cosmetics.ts new file mode 100644 index 0000000..775ac77 --- /dev/null +++ b/src/engine/core/cosmetics.ts @@ -0,0 +1,115 @@ +/** + * cosmetics.ts + * Banner und Brett-Themen (Issue #67, Phase 4): Belohnungen für die Herrscher-Stufe – nur Aussehen. + * Banner = die Fahne auf jedem beherrschten Land (Form und Zeichen; Farbe = Teamfarbe), Thema = Gelände des Bretts + * (nur auf dem eigenen Gerät). Freigeschaltet ab einer Stufe; die Liste wächst mit neuer Kunst. + * Fest-Belohnungen (Issue #93) hängen an keiner Stufe, sondern an einem geschafften Fest (core/events.ts). + */ + +import { activeEvent, seasonOf } from './events'; +import type { EventId } from './events'; + +export const BANNERS = [ + 'classic', + 'pennant', + 'swallowtail', + 'hanging', + 'stripe', + 'cross', + 'star', + 'crown', + 'bat', + 'snowflake', + 'egg', +] as const; +export type Banner = (typeof BANNERS)[number]; + +export const THEMES = [ + 'classic', + 'seasons', + 'winter', + 'autumn', + 'desert', + 'night', + 'halloween', + 'winterfest', + 'easter', +] as const; +export type Theme = (typeof THEMES)[number]; + +/** Ab welcher Stufe (core/renown.ts); Fest-Belohnungen nie über die Stufe (Infinity) */ +export const BANNER_LEVEL: Record = { + classic: 1, + pennant: 3, + swallowtail: 7, + hanging: 12, + stripe: 17, + cross: 22, + star: 27, + crown: 32, + bat: Infinity, + snowflake: Infinity, + egg: Infinity, +}; + +export const THEME_LEVEL: Record = { + classic: 1, + seasons: 1, + winter: 5, + autumn: 10, + desert: 15, + night: 20, + halloween: Infinity, + winterfest: Infinity, + easter: Infinity, +}; + +/** + * „Mit den Jahreszeiten“ (Issue #93): kein eigenes Bild, sondern je Tag eines der freien Themen – + * im Fest dessen Thema, sonst Winter/Herbst, im Frühling die Blütenwiese, im Sommer klassisch. + * owned = freie Themen; was nicht frei ist, fällt auf klassisch zurück. + */ +export function seasonalTheme(date: Date, owned: readonly Theme[]): Theme { + const event = activeEvent(date); + const festive = event ? EVENT_REWARDS[event.id]?.theme : undefined; + const season = seasonOf(date); + const wanted: Theme[] = [ + ...(festive ? [festive] : []), + ...(season === 'winter' ? ['winter' as const] : season === 'autumn' ? ['autumn' as const] : []), + ...(season === 'spring' ? ['easter' as const] : []), + ]; + return wanted.find((theme) => owned.includes(theme)) ?? 'classic'; +} + +/** Belohnung je Fest (Issue #93) */ +export const EVENT_REWARDS: Partial> = { + halloween: { theme: 'halloween', banner: 'bat' }, + winterfest: { theme: 'winterfest', banner: 'snowflake' }, + easter: { theme: 'easter', banner: 'egg' }, +}; + +/** Zu welchem Fest ein Banner bzw. Thema gehört (null = Stufen-Belohnung) */ +export const eventOfBanner = (banner: Banner): EventId | null => + (Object.keys(EVENT_REWARDS) as EventId[]).find((e) => EVENT_REWARDS[e]?.banner === banner) ?? null; +export const eventOfTheme = (theme: Theme): EventId | null => + (Object.keys(EVENT_REWARDS) as EventId[]).find((e) => EVENT_REWARDS[e]?.theme === theme) ?? null; + +export const DEFAULT_BANNER: Banner = 'classic'; +export const DEFAULT_THEME: Theme = 'classic'; + +export const isBanner = (value: unknown): value is Banner => BANNERS.includes(value as Banner); +export const isTheme = (value: unknown): value is Theme => THEMES.includes(value as Theme); + +/** Frei: über die Stufe oder über ein geschafftes Fest (events = earnedEvents der Taten) */ +export const unlockedBanners = (level: number, events: readonly EventId[] = []): Banner[] => + BANNERS.filter((b) => BANNER_LEVEL[b] <= level || events.some((e) => EVENT_REWARDS[e]?.banner === b)); +export const unlockedThemes = (level: number, events: readonly EventId[] = []): Theme[] => + THEMES.filter((t) => THEME_LEVEL[t] <= level || events.some((e) => EVENT_REWARDS[e]?.theme === t)); + +/** Was zwischen zwei Stufen neu freigeschaltet wird (Hinweis nach einer Partie) */ +export function newCosmetics(before: number, after: number): { banners: Banner[]; themes: Theme[] } { + return { + banners: BANNERS.filter((b) => BANNER_LEVEL[b] > before && BANNER_LEVEL[b] <= after), + themes: THEMES.filter((t) => THEME_LEVEL[t] > before && THEME_LEVEL[t] <= after), + }; +} diff --git a/src/engine/core/duel.ts b/src/engine/core/duel.ts new file mode 100644 index 0000000..ad25764 --- /dev/null +++ b/src/engine/core/duel.ts @@ -0,0 +1,210 @@ +/** + * duel.ts + * Online-1v1 („Duell“) – reine Spiellogik für den Server, ohne Netzwerk und ohne Uhr: + * Zeitpunkte kommen als Parameter (now), damit alles deterministisch testbar ist. + * + * - Der Server ist maßgeblich: jeder Zug wird mit derselben Engine geprüft wie im Browser. + * - 60 s je Zug. Läuft die Zeit ab, setzt der Server einen automatischen Zug (gieriger Bot); + * 2 verpasste Züge in Folge = Niederlage. + * - Der Zustand ist ein schlichtes JSON-Objekt (DuelRecord) – nach einem Neustart wird die Partie aus den + * Zügen wiederhergestellt. + * - Speichersparsam: kein Undo-Verlauf (undoLimit 0). Der Server hält ruhende Duelle nur als DuelRecord + * (wenige hundert Bytes) und baut das Duell bei Bedarf aus den Zügen neu auf (~0,5 ms). + */ + +import { BOTS } from './bots'; +import { Game, MATCH_RULES } from './Game'; +import type { RulesVersion } from './Game'; +import { generateBoard } from './MapGenerator'; +import { decodeMoves, encodeMoves } from './replay'; +import type { EncodedMove } from './replay'; +import { mulberry32 } from './rng'; +import { UNIT_TYPES } from './units'; +import { SELECTABLE_TRAIT_IDS } from './WorldRules'; +import type { WorldTrait } from './WorldRules'; +import type { UnitType } from './units'; + +export const DUEL_MOVE_MS = 60_000; +export const DUEL_MAX_MISSED = 2; + +export type DuelStatus = 'waiting' | 'playing' | 'finished'; +export type DuelEndReason = 'score' | 'forfeit' | 'resign'; + +export interface DuelResult { + /** Sitzplatz des Siegers (0/1) oder null bei Gleichstand */ + winner: number | null; + reason: DuelEndReason; + scores: [number, number]; +} + +export interface DuelRecord { + id: string; + seed: number; + /** Regelwerk; fehlt bei Duellen von vor Phase 11 = 'classic' */ + rules?: RulesVersion; + /** Spielvariante (nur ungewertete Duelle: Einladungslink, Freundes-Herausforderung); fehlt = klassisch 9 × 9 */ + variant?: DuelVariant; + status: DuelStatus; + moves: EncodedMove[]; + /** Sitzplatz am Zug (aus den Zügen abgeleitet; gespeichert, damit der Server den Stand ohne Engine kennt) */ + current: number; + /** Verpasste Züge in Folge je Sitzplatz */ + missed: [number, number]; + /** Ende der Bedenkzeit des Spielers am Zug (ms seit 1970) oder null */ + deadline: number | null; + result: DuelResult | null; + /** Gewertet (Ruhm/Elo, nur Server): schnelles Spiel zwischen zwei Konten und dessen Revanchen */ + rated?: boolean; + /** Ruhm vorher und Änderung je Platz – gesetzt, sobald der Server das Ergebnis eingetragen hat */ + elo?: { before: [number, number]; delta: [number, number] } | null; + /** Sieg dem Konto gutgeschrieben (Profilbilder, nur Server) */ + winCounted?: boolean; +} + +/** Varianten eines ungewerteten Online-Duells (wie „Eigenes Spiel“) */ +export interface DuelVariant { + size?: number; + bandits?: boolean; + fog?: boolean; + trait?: WorldTrait; +} + +/** Ungeprüfte Variante (vom Client) → gültige Variante oder undefined (= klassisch) */ +export function parseDuelVariant(raw: unknown): DuelVariant | undefined { + if (!raw || typeof raw !== 'object') return undefined; + const r = raw as Record; + const v: DuelVariant = {}; + if (r.size === 12 || r.size === 15) v.size = r.size; + if (r.bandits === true) v.bandits = true; + if (r.fog === true) v.fog = true; + if (typeof r.trait === 'string' && (SELECTABLE_TRAIT_IDS as readonly string[]).includes(r.trait)) { + v.trait = r.trait as WorldTrait; + } + return Object.keys(v).length > 0 ? v : undefined; +} + +export type DuelMoveError = 'FINISHED' | 'NOT_STARTED' | 'NOT_YOUR_TURN' | 'STALE' | 'ILLEGAL'; + +export class Duel { + readonly game: Game; + + private constructor(readonly record: DuelRecord) { + const v = record.variant ?? {}; + this.game = new Game(generateBoard(record.seed, { trait: v.trait, size: v.size }), { + players: 2, + undoLimit: 0, + rules: record.rules ?? 'classic', + bandits: v.bandits, + fog: v.fog, + }); + for (const m of decodeMoves(record.moves)) { + if (!this.game.place(m.unit, m.cell).ok) throw new Error(`Duell ${record.id}: gespeicherter Zug ungültig`); + } + record.current = this.game.current; + } + + static create(id: string, seed: number, rules: RulesVersion = MATCH_RULES, variant?: DuelVariant): Duel { + return new Duel({ + id, + seed, + rules, + ...(variant ? { variant } : {}), + status: 'waiting', + moves: [], + current: 0, + missed: [0, 0], + deadline: null, + result: null, + }); + } + + /** + * Aus gespeichertem Zustand wiederherstellen (Server-Neustart, ruhendes Duell). Standard: eigene Kopie; + * shared = true arbeitet direkt auf dem übergebenen Objekt (der Server hält es ohnehin). + */ + static restore(record: DuelRecord, shared = false): Duel { + const duel = new Duel(shared ? record : structuredClone(record)); + // Läuft ein Duell, das nach heutigen Regeln schon entschieden ist (vorzeitiger Sieg), wird es beendet + if (duel.record.status === 'playing' && duel.game.isGameOver()) duel.finish(duel.game.winner(), 'score'); + return duel; + } + + get current(): number { + return this.game.current; + } + + /** Beide Plätze besetzt: Partie beginnt, die Uhr läuft. */ + start(now: number): void { + if (this.record.status !== 'waiting') return; + this.record.status = 'playing'; + this.record.deadline = now + DUEL_MOVE_MS; + } + + /** + * Zug von Sitzplatz `seat`. n = Zugnummer, auf die sich der Client bezieht (moves.length) – + * veraltete Anfragen (z. B. doppelt gesendet, Gegner hat inzwischen gezogen) werden abgewiesen. + */ + move( + seat: number, + unit: UnitType, + cell: number, + n: number, + now: number + ): { ok: true } | { ok: false; error: DuelMoveError } { + const r = this.record; + if (r.status === 'finished') return { ok: false, error: 'FINISHED' }; + if (r.status !== 'playing') return { ok: false, error: 'NOT_STARTED' }; + if (n !== r.moves.length) return { ok: false, error: 'STALE' }; + if (seat !== this.game.current) return { ok: false, error: 'NOT_YOUR_TURN' }; + if (!UNIT_TYPES.includes(unit) || !Number.isInteger(cell) || cell < 0 || cell >= this.game.board.cells) { + return { ok: false, error: 'ILLEGAL' }; + } + if (!this.game.place(unit, cell).ok) return { ok: false, error: 'ILLEGAL' }; + r.moves.push(...encodeMoves([{ unit, cell }])); + r.missed[seat] = 0; + this.afterMove(now); + return { ok: true }; + } + + /** + * Bedenkzeit abgelaufen? Dann automatischer Zug für den Spieler am Zug (gieriger Bot, geseedet → nach einem + * Neustart identisch); beim zweiten verpassten Zug in Folge verliert er. Liefert, ob sich etwas geändert hat. + */ + checkTimeout(now: number): boolean { + const r = this.record; + if (r.status !== 'playing' || r.deadline === null || now < r.deadline) return false; + const seat = this.game.current; + r.missed[seat]++; + if (r.missed[seat] >= DUEL_MAX_MISSED) { + this.finish(1 - seat, 'forfeit'); + return true; + } + const bot = BOTS.greedy(this.game, mulberry32(r.seed ^ (r.moves.length * 2654435761))); + this.game.place(bot.unit, bot.cell); + r.moves.push(...encodeMoves([bot])); + this.afterMove(now); + return true; + } + + resign(seat: number): boolean { + if (this.record.status !== 'playing') return false; + this.finish(1 - seat, 'resign'); + return true; + } + + private afterMove(now: number): void { + this.record.current = this.game.current; + if (this.game.isGameOver()) { + this.finish(this.game.winner(), 'score'); + } else { + this.record.deadline = now + DUEL_MOVE_MS; + } + } + + private finish(winner: number | null, reason: DuelEndReason): void { + const r = this.record; + r.status = 'finished'; + r.deadline = null; + r.result = { winner, reason, scores: [this.game.scores[0], this.game.scores[1]] }; + } +} diff --git a/src/engine/core/events.ts b/src/engine/core/events.ts new file mode 100644 index 0000000..505ee5e --- /dev/null +++ b/src/engine/core/events.ts @@ -0,0 +1,100 @@ +/** + * events.ts + * Jahreszeiten und Feste (Issue #93): reiner Kalender, ohne DOM, in Browser und Server gleich. + * Datumsangaben wie die Tageskarte in UTC (`YYYY-MM-DD`, core/rng.ts dailyKey). + * + * - Jahreszeiten: leichte Ebene, immer eine aktiv (Nordhalbkugel, Tage wie in der Astronomie gerundet). + * - Feste: Halloween, Winterfest und Ostern mit eigener Belohnung (Brett-Thema und Banner, core/cosmetics.ts). + * Wer während eines Fests auf EVENT_MAPS Karten mindestens einen Stern holt, behält die Belohnung für immer. + */ + +export type SeasonId = 'spring' | 'summer' | 'autumn' | 'winter'; +export type EventId = 'halloween' | 'winterfest' | 'easter'; + +export interface GameEvent { + id: EventId; + /** Jahr, in dem das Fest beginnt (Winterfest 2026 läuft bis Januar 2027) */ + year: number; + /** Erster und letzter Tag (einschließlich), `YYYY-MM-DD` */ + start: string; + end: string; +} + +/** Karten mit mindestens einem Stern während des Fests, um die Belohnung zu behalten */ +export const EVENT_MAPS = 3; + +const iso = (year: number, month: number, day: number) => + new Date(Date.UTC(year, month - 1, day)).toISOString().slice(0, 10); +const shift = (date: string, days: number) => { + const d = new Date(`${date}T00:00:00Z`); + d.setUTCDate(d.getUTCDate() + days); + return d.toISOString().slice(0, 10); +}; + +/** Ostersonntag (gregorianisch, Algorithmus von Meeus/Jones/Butcher) als `YYYY-MM-DD` */ +export function easterSunday(year: number): string { + const a = year % 19; + const b = Math.floor(year / 100); + const c = year % 100; + const d = Math.floor(b / 4); + const e = b % 4; + const f = Math.floor((b + 8) / 25); + const g = Math.floor((b - f + 1) / 3); + const h = (19 * a + b - d - g + 15) % 30; + const i = Math.floor(c / 4); + const k = c % 4; + const l = (32 + 2 * e + 2 * i - h - k) % 7; + const m = Math.floor((a + 11 * h + 22 * l) / 451); + const month = Math.floor((h + l - 7 * m + 114) / 31); + const day = ((h + l - 7 * m + 114) % 31) + 1; + return iso(year, month, day); +} + +/** Feste, die in diesem Jahr beginnen */ +export function eventsOfYear(year: number): GameEvent[] { + const easter = easterSunday(year); + return [ + { id: 'easter', year, start: shift(easter, -7), end: shift(easter, 7) }, + { id: 'halloween', year, start: iso(year, 10, 25), end: iso(year, 11, 2) }, + { id: 'winterfest', year, start: iso(year, 12, 15), end: iso(year + 1, 1, 6) }, + ]; +} + +export const dayOf = (date: Date): string => date.toISOString().slice(0, 10); + +/** Laufendes Fest an diesem Tag (oder null) */ +export function activeEvent(date: Date): GameEvent | null { + const day = dayOf(date); + const year = date.getUTCFullYear(); + // Das Winterfest des Vorjahrs reicht in den Januar + return [...eventsOfYear(year - 1), ...eventsOfYear(year)].find((e) => e.start <= day && day <= e.end) ?? null; +} + +/** Nächstes Fest ab diesem Tag (auch das laufende) */ +export function nextEvent(date: Date): GameEvent { + const day = dayOf(date); + const year = date.getUTCFullYear(); + return [...eventsOfYear(year - 1), ...eventsOfYear(year), ...eventsOfYear(year + 1)].find((e) => e.end >= day)!; +} + +/** Jahreszeit (Nordhalbkugel): Frühling ab 20. März, Sommer ab 21. Juni, Herbst ab 23. September, Winter ab 21. Dezember */ +export function seasonOf(date: Date): SeasonId { + const md = dayOf(date).slice(5); + if (md >= '03-20' && md < '06-21') return 'spring'; + if (md >= '06-21' && md < '09-23') return 'summer'; + if (md >= '09-23' && md < '12-21') return 'autumn'; + return 'winter'; +} + +/** Merkmal (Tat) für ein geschafftes Fest, z. B. `event:halloween:2026` – gespeichert wie die Taten der Erfolge */ +export const eventFeat = (event: Pick): string => `event:${event.id}:${event.year}`; + +/** Feste, deren Belohnung diese Taten freischalten (egal in welchem Jahr) */ +export function earnedEvents(feats: readonly string[]): EventId[] { + const ids = new Set(); + for (const feat of feats) { + const match = /^event:(halloween|winterfest|easter):\d{4}$/.exec(feat); + if (match) ids.add(match[1] as EventId); + } + return [...ids]; +} diff --git a/src/engine/core/feats.ts b/src/engine/core/feats.ts new file mode 100644 index 0000000..bc78d9d --- /dev/null +++ b/src/engine/core/feats.ts @@ -0,0 +1,33 @@ +/** + * feats.ts + * Taten einer Partie (Erfolge, Issue #67): eigenes Modul ohne Abhängigkeit zu progress.ts, damit die Prüfung beim + * Nachspielen (progress.ts) sie nutzen kann. Liste und Erfolge dazu: achievements.ts. + */ + +import type { Game } from './Game'; +import { UNIT_TYPES } from './units'; + +export const FEATS = ['allLands', 'gold150', 'gold200', 'raider', 'noKnights', 'noBishops', 'frugal'] as const; +export type Feat = (typeof FEATS)[number]; + +/** Höchstens so viele Figuren für „Sparsam“ (drei Sterne auf 9 × 9) */ +export const FRUGAL_PIECES = 26; + +/** + * Taten einer Partie auf einer freien Karte (nicht im Feldzug – dort sind Figuren oft gesperrt). stars = die + * Sterne dieser Partie. + */ +export function gameFeats(game: Game, stars: number): Feat[] { + const feats: Feat[] = []; + const board = game.board; + const pieces = UNIT_TYPES.reduce((n, u) => n + game.count(u, 0), 0); + if (pieces === 0) return feats; + if (game.regionCounts()[0] === board.zoneCount) feats.push('allLands'); + if (board.size === 9 && game.score >= 150) feats.push('gold150'); + if (board.size === 9 && game.score >= 200) feats.push('gold200'); + if (game.camps.length > 0 && game.campAlive.every((alive) => alive === 0)) feats.push('raider'); + if (stars === 3 && game.count('DRONE', 0) === 0) feats.push('noKnights'); + if (stars === 3 && game.count('HIVESCOUT', 0) === 0) feats.push('noBishops'); + if (stars === 3 && board.size === 9 && pieces <= FRUGAL_PIECES) feats.push('frugal'); + return feats; +} diff --git a/src/engine/core/goals.ts b/src/engine/core/goals.ts new file mode 100644 index 0000000..98e1510 --- /dev/null +++ b/src/engine/core/goals.ts @@ -0,0 +1,113 @@ +/** + * goals.ts + * Sterne-Ziele einer Solo-Karte (Phase 11d): Gold-Ziel und Lande-Ziel, berechnet aus Bot-Partien auf genau + * dieser Karte – dadurch fair für jede Regel des Reichs (keine festen Schwellen mehr, vgl. Phase 5e). + * Deterministisch (feste Seeds): Browser und Server kommen auf dieselben Ziele. + * + * ★ Gold-Ziel erreicht ★ Lande-Ziel erreicht ★ beides in derselben Partie + * Ein Land beherrscht man im Solo, wenn man mindestens die Hälfte seiner bebaubaren Felder beansprucht. + */ + +import type { Board } from './Board'; +import { BOTS, realmValue } from './bots'; +import type { BotMove } from './bots'; +import { Game } from './Game'; +import { mulberry32, randInt } from './rng'; +import type { Rng } from './rng'; +import { UNIT_TYPES } from './units'; +import type { UnitType } from './units'; + +/** Erhöhen, wenn sich die Berechnung ändert (gespeicherte Sterne gelten je Version) */ +export const GOALS_VERSION = 1; + +/** + * Version der Ziele freier Karten (inkl. Tageskarte): 2 = auch 9 × 9 mit greedy-Partien (Issue #92). + * Feldzug und Woche bleiben bei GOALS_VERSION – sonst wären Feldzug-Fortschritt und Wochen-Rangliste weg. + */ +export const MAP_GOALS_VERSION = 2; + +export interface MapGoals { + gold: number; + lands: number; +} + +/** Solo-Bot, der auf Lande spielt: bester Zug nach realmValue (Lande zählen viel, Gold wenig). */ +function landsMove(game: Game, rng: Rng): BotMove { + let best: BotMove[] = []; + let bestValue = -Infinity; + for (const unit of UNIT_TYPES) { + const legal = game.legalMoves(unit); + for (let cell = 0; cell < legal.length; cell++) { + if (!legal[cell]) continue; + const sim = game.clone(); + sim.place(unit, cell); + const v = realmValue(sim, 0); + if (v > bestValue) { + bestValue = v; + best = [{ unit, cell, net: v }]; + } else if (v === bestValue) best.push({ unit, cell, net: v }); + } + } + return best[randInt(rng, best.length)]; +} + +/** Spielvarianten, die die Ziele verändern (Räuber bringen Beute, Nebel schränkt ein) */ +export interface GoalVariant { + bandits?: boolean; + fog?: boolean; +} + +function playout( + board: Board, + pick: (game: Game, rng: Rng) => BotMove, + seed: number, + units: readonly UnitType[] | null, + variant: GoalVariant = {} +) { + const game = new Game(board, { undoLimit: 0, allowedUnits: units ?? undefined, ...variant }); + const rng = mulberry32(seed); + while (!game.isGameOver()) { + const m = pick(game, rng); + game.place(m.unit, m.cell); + } + return { gold: game.score, lands: game.regionCounts()[0] }; +} + +/** + * Ziele dieser Karte: Gold wie der „smart“-Bot, Lande wie der beste Lande-Bot (gerundet auf 5 Gold). + * units: nur diese Figuren (Feldzug-Stationen führen Figuren nach und nach ein); null = alle. + * campaign: Feldzug-Station – auf 9 × 9 ohne greedy-Partien (die Stationen sind so abgestimmt, Issue #92). + */ +export function computeGoals( + board: Board, + units: readonly UnitType[] | null = null, + variant: GoalVariant = {}, + campaign = false +): MapGoals { + const seed = board.seed >>> 0; + // Gold: beste von drei Bot-Partien (eine einzelne ist zu schwach – Menschen spielen besser als die Bots). + // Größere Karten: je eine Partie (12 × 12 kostet sonst ~3×, 15 × 15 ~6× so viel – auch auf dem Server). + const big = board.size > 9; + const smartSeeds = big ? [0x51] : [0x51, 0x52, 0x53]; + const landsSeeds = big ? [0x1a] : [0x1a, 0x2b]; + const smart = smartSeeds.map((k) => playout(board, BOTS.smart, seed ^ k, units, variant)); + const lands = landsSeeds.map((k) => playout(board, landsMove, seed ^ k, units, variant)); + // Eine einzelne smart-Partie kann verunglücken (Ziel teils nur 10–30 % des Erreichbaren, Issue #19; auf 9 × 9 + // bei etwa jeder zehnten Karte, Issue #92). Zwei Partien des billigeren greedy-Bots fangen das ab. + const greedy = big || !campaign ? [0x61, 0x62].map((k) => playout(board, BOTS.greedy, seed ^ k, units, variant)) : []; + const gold = Math.max(...smart.map((r) => r.gold), ...greedy.map((r) => r.gold)); + return { + gold: Math.max(5, Math.floor(gold / 5) * 5), + lands: Math.max(1, ...smart.map((r) => r.lands), ...lands.map((r) => r.lands)), + }; +} + +/** Sterne einer (laufenden oder beendeten) Partie: Gold, Lande, beides */ +export function starsOf(game: Game, goals: MapGoals): { gold: boolean; lands: boolean; both: boolean; count: number } { + const gold = game.score >= goals.gold; + const lands = game.regionCounts()[0] >= goals.lands; + const both = gold && lands; + return { gold, lands, both, count: Number(gold) + Number(lands) + Number(both) }; +} + +export { landsMove as _landsMoveForTests }; diff --git a/src/engine/core/progress.ts b/src/engine/core/progress.ts new file mode 100644 index 0000000..f9a969a --- /dev/null +++ b/src/engine/core/progress.ts @@ -0,0 +1,165 @@ +/** + * progress.ts + * Sterne-Fortschritt fürs Konto (Phase 12b): Speicher-Schlüssel (Browser und Server gleich) und die Prüfung einer + * eingereichten Partie – nachspielen mit derselben Engine, Ziele selbst berechnen, Sterne zählen. Der Server + * speichert nur, was er so nachgerechnet hat. + */ + +import campaignData from '../campaign/campaign.json'; +import orcCampaignData from '../campaign/orc.json'; +import pirateCampaignData from '../campaign/pirate.json'; +import { chapterOf } from './campaign'; +import type { CampaignId, CampaignLevel } from './campaign'; +import { Game } from './Game'; +import { gameFeats } from './feats'; +import { isPlayableWeek, weeklySpec, weeklyStarsKey } from './weekly'; +import type { Feat } from './feats'; +import { GOALS_VERSION, MAP_GOALS_VERSION, computeGoals, starsOf } from './goals'; +import type { GoalVariant, MapGoals } from './goals'; +import { GENERATOR_VERSION, generateBoard, rolledTrait } from './MapGenerator'; +import { applyMoves } from './replay'; +import { dailySeed, mapCodeFromSeed, seedFromMapId } from './rng'; +import { baseMapId, sizeFromMapId } from './Board'; +import type { UnitType } from './units'; +import { SELECTABLE_TRAIT_IDS } from './WorldRules'; +import type { WorldTrait } from './WorldRules'; + +/** Stationen des ersten Feldzugs (Königreich) */ +export const CAMPAIGN_LEVELS = campaignData as CampaignLevel[]; + +/** Stationen je Feldzug (Issue #53); die Stationen der Völker tragen ihren Feldzug */ +export const CAMPAIGN_LEVELS_BY_ID: Record = { + kingdom: CAMPAIGN_LEVELS, + orc: orcCampaignData as CampaignLevel[], + pirate: pirateCampaignData as CampaignLevel[], +}; + +export const ALL_CAMPAIGN_LEVELS: readonly CampaignLevel[] = Object.values(CAMPAIGN_LEVELS_BY_ID).flat(); + +/** + * Schlüssel der besten Sterne einer freien Karte (Map-Code, Regel des Reichs, Ziele-Version; Räuber/Nebel als + * Zusatz – sie verändern die Ziele, ohne Varianten bleibt der Schlüssel wie bisher). + */ +export function mapStarsKey(mapId: string, trait: string, variant: GoalVariant = {}): string { + return `${mapId}|${trait}|${MAP_GOALS_VERSION}${variant.bandits ? '|b' : ''}${variant.fog ? '|f' : ''}`; +} + +/** + * Schlüssel der Tageskarte von heute oder gestern (UTC) – wer kurz vor Mitternacht beginnt, darf nach Mitternacht + * fertig werden (wie die Tagesserie). Für die beste Partie des Tages (Issue #89). + */ +export function isDailyKey(key: string, now = new Date()): boolean { + return [0, 1].some((back) => { + const seed = dailySeed(new Date(now.getTime() - back * 86_400_000)); + return key === mapStarsKey(mapCodeFromSeed(seed), rolledTrait(seed)); + }); +} + +/** Schlüssel der besten Sterne einer Feldzug-Station */ +export function campaignStarsKey(levelId: string): string { + return `campaign:${levelId}|${GOALS_VERSION}`; +} + +/** Schlüssel der besten Sterne eines Rätsels (Prüfung: puzzleProgress.ts) */ +export function puzzleStarsKey(puzzleId: string): string { + return `puzzle:${puzzleId}`; +} + +/** Rätsel-ID aus einem Fortschritts-Schlüssel, sonst null */ +export function puzzleIdOfKey(key: string): string | null { + return key.startsWith('puzzle:') ? key.slice('puzzle:'.length) : null; +} + +/** Was gespielt wurde: eine freie Karte (Code + Gesetz) oder eine Feldzug-Station */ +export type ProgressTarget = + | { kind: 'map'; mapId: string; trait: string; bandits?: boolean; fog?: boolean } + | { kind: 'campaign'; levelId: string } + | { kind: 'puzzle'; puzzleId: string } + | { kind: 'weekly'; week: string }; + +export type ProgressResult = + | { ok: true; key: string; stars: number; gold: number; lands: number; feats?: Feat[] } + | { ok: false; error: 'INVALID_TARGET' | 'INVALID_MOVES' | 'OUTDATED_CLIENT' }; + +/** Ziele je Karte + Figuren zwischenspeichern (≈ 60 ms Rechenzeit; höchstens 256 Einträge, wenige Bytes je Eintrag) */ +const goalsCache = new Map(); + +function cachedGoals( + seed: number, + trait: WorldTrait, + units: readonly UnitType[] | null, + variant: GoalVariant, + board = generateBoard(seed, { trait }), + campaign = false +) { + const key = `${campaign ? 'c' : ''}${board.size}:${seed}:${trait}:${units?.join(',') ?? '*'}:${variant.bandits ? 'b' : ''}${variant.fog ? 'f' : ''}`; + let goals = goalsCache.get(key); + if (goals) { + goalsCache.delete(key); + } else { + goals = computeGoals(board, units, variant, campaign); + if (goalsCache.size >= 256) goalsCache.delete(goalsCache.keys().next().value!); + } + goalsCache.set(key, goals); + return goals; +} + +/** Ziele einer Karte samt Varianten aus dem Cache (Eroberungszug: je Stufe) */ +export function cachedGoalsOf(game: Game): MapGoals { + const board = game.board; + return cachedGoals(board.seed, game.trait, game.allowedUnits, { bandits: game.bandits, fog: game.fog }, board); +} + +/** + * Eingereichte Partie prüfen: Karte bzw. Station bestimmen, Züge nachspielen, Sterne zählen. + * generatorVersion muss passen (andere Version = andere Karte). + */ +export function verifyProgress(target: unknown, moves: unknown, generatorVersion: unknown): ProgressResult { + if (generatorVersion !== GENERATOR_VERSION) return { ok: false, error: 'OUTDATED_CLIENT' }; + if (!target || typeof target !== 'object') return { ok: false, error: 'INVALID_TARGET' }; + const t = target as Record; + + let seed: number; + let trait: WorldTrait; + let units: readonly UnitType[] | null = null; + let variant: GoalVariant = {}; + let size = 9; + let key: string; + if (t.kind === 'map' && typeof t.mapId === 'string' && SELECTABLE_TRAIT_IDS.includes(t.trait as WorldTrait)) { + if (!/^[0-9A-Z-]{1,16}$/.test(t.mapId)) return { ok: false, error: 'INVALID_TARGET' }; + seed = seedFromMapId(baseMapId(t.mapId)); + size = sizeFromMapId(t.mapId); + trait = t.trait as WorldTrait; + variant = { bandits: t.bandits === true, fog: t.fog === true }; + key = mapStarsKey(t.mapId, trait, variant); + } else if (t.kind === 'campaign' && typeof t.levelId === 'string') { + const level = ALL_CAMPAIGN_LEVELS.find((l) => l.id === t.levelId); + // Rätsel-Stationen haben eigene Sterne (Rätsel-Fortschritt), keine Karten-Ziele + if (!level || level.puzzle) return { ok: false, error: 'INVALID_TARGET' }; + seed = level.seed; + trait = level.trait; + const chapter = chapterOf(level); + units = chapter.units; + variant = { bandits: chapter.bandits === true, fog: chapter.fog === true }; + key = campaignStarsKey(level.id); + } else if (t.kind === 'weekly' && isPlayableWeek(t.week)) { + // Herausforderung der Woche (Issue #67): Karte und Wendung allein aus der Woche + const spec = weeklySpec(t.week); + seed = spec.seed; + trait = spec.trait; + size = spec.size; + units = spec.units; + variant = { bandits: spec.bandits, fog: spec.fog }; + key = weeklyStarsKey(t.week); + } else { + return { ok: false, error: 'INVALID_TARGET' }; + } + + const board = generateBoard(seed, { trait, size }); + const game = new Game(board, { undoLimit: 0, allowedUnits: units ?? undefined, ...variant }); + if (!applyMoves(game, moves) || game.moves.length === 0) return { ok: false, error: 'INVALID_MOVES' }; + const stars = starsOf(game, cachedGoals(seed, trait, units, variant, board, t.kind === 'campaign')); + // Taten für Erfolge (Issue #67) nur auf freien Karten – im Feldzug sind Figuren oft gesperrt + const feats = t.kind === 'map' ? gameFeats(game, stars.count) : []; + return { ok: true, key, stars: stars.count, gold: game.score, lands: game.regionCounts()[0], feats }; +} diff --git a/src/engine/core/puzzle.ts b/src/engine/core/puzzle.ts new file mode 100644 index 0000000..fc91bbd --- /dev/null +++ b/src/engine/core/puzzle.ts @@ -0,0 +1,219 @@ +/** + * puzzle.ts + * Waben-Rätsel: eine Stellung (Map + vorgegebene Züge), ein Ziel, wenige Züge, evtl. nur bestimmte Einheiten. + * Die Rätsel erzeugt scripts/make-puzzles.mjs offline mit dem exakten Löser unten (Par = bestes Ergebnis); + * Spiel und Tests nutzen dieselbe Engine, daher stimmen Par und Lösung garantiert. + * + * Ziele: + * - energy: möglichst viel Schwarm-Energie nach höchstens `moves` Zügen (Sterne ab stars[i] Energie) + * - resources: alle noch nicht abgebauten Rohstoffe abbauen, in möglichst wenigen Zügen (Sterne bis stars[i] Züge) + */ + +import { CELLS } from './Board'; +import { Game } from './Game'; +import type { Move } from './Game'; +import { generateBoard } from './MapGenerator'; +import { decodeMoves } from './replay'; +import type { EncodedMove } from './replay'; +import { UNIT_TYPES } from './units'; +import type { UnitType } from './units'; +import type { WorldTrait } from './WorldRules'; + +export type PuzzleGoal = 'energy' | 'resources'; + +export interface PuzzleSpec { + /** Stabile ID (Fortschritt hängt daran), z. B. „c1-03“ oder „d-017“ */ + id: string; + /** Kapitel 1… (0 = nur Tagesrätsel-Vorrat) */ + chapter: number; + seed: number; + trait: WorldTrait; + /** Generator-Version, mit der Map und Par berechnet wurden (Map-Generator = Vertrag) */ + generatorVersion: number; + /** Vorgegebene Züge (Solo), danach beginnt das Rätsel */ + preset: EncodedMove[]; + /** Erlaubte Züge */ + moves: number; + /** Erlaubte Einheiten (null = alle) */ + units: UnitType[] | null; + goal: PuzzleGoal; + /** Sterne 1–3: energy = Mindest-Energie, resources = höchstens so viele Züge */ + stars: [number, number, number]; + /** Bestes mögliches Ergebnis (energy: Energie, resources: Züge) */ + par: number; + /** Eine optimale Zugfolge (ab der Startstellung) */ + solution: EncodedMove[]; +} + +/** Startstellung des Rätsels: Vorgaben gespielt, danach Einheiten-/Zug-Grenzen, Undo bis zur Startstellung. */ +export function createPuzzleGame(spec: PuzzleSpec, undoLimit = Infinity): Game { + const game = new Game(generateBoard(spec.seed, { trait: spec.trait }), { undoLimit: 0 }); + for (const move of decodeMoves(spec.preset)) { + if (!game.place(move.unit, move.cell).ok) throw new Error(`Rätsel ${spec.id}: Vorgabe ungültig`); + } + game.setAllowedUnits(spec.units); + game.setMovesLeft(spec.moves); + game.setUndoLimit(undoLimit); + return game; +} + +/** Rohstoff-Felder, die noch nicht abgebaut sind. */ +export function unminedResources(game: Game): number { + let n = 0; + for (let i = 0; i < CELLS; i++) if (game.board.resource[i] && !game.mined[i]) n++; + return n; +} + +export interface PuzzleProgress { + /** Züge seit Rätselbeginn */ + used: number; + /** Rätsel vorbei: keine Züge mehr (oder Rohstoff-Ziel erreicht) */ + finished: boolean; + /** Ziel erreicht (mind. 1 Stern) */ + solved: boolean; + stars: 0 | 1 | 2 | 3; + /** energy: aktuelle Energie; resources: verbleibende Rohstoffe */ + value: number; +} + +/** Höchstens so viele Sterne, sobald ein Tipp genutzt wurde (Issue #88) */ +export const HINT_STAR_CAP = 2; + +/** hinted: mindestens ein Tipp genutzt → höchstens HINT_STAR_CAP Sterne */ +export function puzzleProgress(spec: PuzzleSpec, game: Game, hinted = false): PuzzleProgress { + const used = game.moves.length - spec.preset.length; + const cap = (n: number) => Math.min(n, hinted ? HINT_STAR_CAP : 3) as 0 | 1 | 2 | 3; + if (spec.goal === 'energy') { + const value = game.score; + const stars = cap(spec.stars.filter((s) => value >= s).length); + return { used, finished: game.isGameOver(), solved: stars > 0, stars, value }; + } + const value = unminedResources(game); + const done = value === 0; + const stars = done ? cap(spec.stars.filter((s) => used <= s).length) : 0; + return { used, finished: done || game.isGameOver(), solved: stars > 0, stars, value }; +} + +/** Stellungen, die der Tipp höchstens durchsucht (läuft im Browser, muss schnell bleiben) */ +const HINT_BUDGET = 1_000; + +/** Tipp: nächster Zug – oder so viele Züge zurücknehmen, dann geht es auf dem Lösungsweg weiter */ +export type PuzzleHint = { kind: 'move'; move: Move } | { kind: 'undo'; count: number }; + +/** + * Tipp (Issue #88) für die aktuelle Stellung: + * 1. Eigene Züge stecken alle in der gespeicherten Lösung (Reihenfolge egal: die Energie hängt nur von der Menge der + * Steine ab, siehe key()) → der nächste noch fehlende Lösungszug, der jetzt gültig ist. + * 2. Sonst kurze Suche ab hier (kleines Budget, damit der Knopf sofort antwortet). + * 3. Findet die nichts: so viele Züge zurücknehmen, bis wieder nur Lösungszüge liegen. + * null: kein Zug verbessert das Ergebnis mehr. + */ +export function puzzleHint(spec: PuzzleSpec, game: Game, budget = HINT_BUDGET): PuzzleHint | null { + const solution = decodeMoves(spec.solution); + const played = game.moves.slice(spec.preset.length); + const same = (a: Move, b: Move) => a.unit === b.unit && a.cell === b.cell; + const inSolution = (m: Move) => solution.some((s) => same(m, s)); + const left = game.movesLeft ?? 0; + if (left > 0 && !puzzleProgress(spec, game).finished) { + if (played.every(inSolution)) { + const next = solution.find((s) => !played.some((m) => same(m, s)) && game.legalMoves(s.unit)[s.cell] === 1); + if (next) return { kind: 'move', move: next }; + if (played.length === solution.length) return null; // Lösung komplett: mehr geht nicht + } + try { + const line = + spec.goal === 'energy' + ? solveEnergy(game, left, budget).line + : (solveResources(game, left, budget)?.line ?? []); + if (line[0]) return { kind: 'move', move: line[0] }; + } catch (err) { + if (!(err instanceof SearchBudgetExceeded)) throw err; + } + } + // Zurück bis zum letzten Stand, der nur aus Lösungszügen besteht + let keep = 0; + while (keep < played.length && inSolution(played[keep])) keep++; + return keep < played.length ? { kind: 'undo', count: played.length - keep } : null; +} + +// ========================================== +// LÖSER (exakt, für kurze Rätsel; nur offline und in Tests) +// ========================================== + +/** Alle gültigen Folgestellungen. */ +function children(game: Game): { move: Move; next: Game }[] { + const out: { move: Move; next: Game }[] = []; + for (const unit of UNIT_TYPES) { + const bits = game.legalMoves(unit); + for (let cell = 0; cell < CELLS; cell++) { + if (!bits[cell]) continue; + const next = game.clone(); + next.place(unit, cell); + out.push({ move: { unit, cell }, next }); + } + } + return out; +} + +/** + * Stellungsschlüssel: Steine und Abbau-Stand. Die Energie hängt (ohne Abschneiden bei 0 – Kosten werden nur + * bezahlt, wenn sie gedeckt sind) nur von der Menge der Steine ab, nicht von der Reihenfolge → Zugumstellungen + * werden nur einmal durchsucht. + */ +function key(game: Game, depth: number): string { + return String.fromCharCode(...game.piece) + String.fromCharCode(...game.mined) + depth; +} + +export class SearchBudgetExceeded extends Error {} + +/** + * Höchste erreichbare Energie mit höchstens `depth` weiteren Zügen und eine Zugfolge dorthin. + * Wirft SearchBudgetExceeded, wenn mehr als `budget` Stellungen nötig wären. + */ +export function solveEnergy(game: Game, depth: number, budget = 2_000_000): { value: number; line: Move[] } { + const memo = new Map(); + let nodes = 0; + const search = (g: Game, d: number): { value: number; line: Move[] } => { + if (d === 0) return { value: g.score, line: [] }; + const k = key(g, d); + const hit = memo.get(k); + if (hit) return hit; + if (++nodes > budget) throw new SearchBudgetExceeded(); + let best = { value: g.score, line: [] as Move[] }; + for (const { move, next } of children(g)) { + const r = search(next, d - 1); + if (r.value > best.value) best = { value: r.value, line: [move, ...r.line] }; + } + memo.set(k, best); + return best; + }; + return search(game, depth); +} + +/** Wenigste Züge (≤ maxDepth), um alle Rohstoffe abzubauen, samt Zugfolge – oder null, wenn unmöglich. */ +export function solveResources( + game: Game, + maxDepth: number, + budget = 2_000_000 +): { value: number; line: Move[] } | null { + let nodes = 0; + for (let depth = 0; depth <= maxDepth; depth++) { + const failed = new Set(); + const search = (g: Game, d: number): Move[] | null => { + if (unminedResources(g) === 0) return []; + if (d === 0) return null; + const k = key(g, d); + if (failed.has(k)) return null; + if (++nodes > budget) throw new SearchBudgetExceeded(); + for (const { move, next } of children(g)) { + const line = search(next, d - 1); + if (line) return [move, ...line]; + } + failed.add(k); + return null; + }; + const line = search(game, depth); + if (line) return { value: line.length, line }; + } + return null; +} diff --git a/src/engine/core/puzzleProgress.ts b/src/engine/core/puzzleProgress.ts new file mode 100644 index 0000000..9df9cc1 --- /dev/null +++ b/src/engine/core/puzzleProgress.ts @@ -0,0 +1,39 @@ +/** + * puzzleProgress.ts + * Rätsel-Sterne fürs Konto (Issue #18): der Server spielt die eingereichten Züge nach der Vorgabe nach und zählt die + * Sterne selbst – wie bei Karten und Feldzug. Eigenes Modul, damit die Rätsel-Daten (puzzles.json) nur im + * Server-Bündel landen und nicht im Haupt-Bündel des Spiels (dort werden sie erst mit dem ersten Rätsel geladen). + */ + +import data from '../puzzles/puzzles.json'; +import { GENERATOR_VERSION } from './MapGenerator'; +import { puzzleStarsKey } from './progress'; +import type { ProgressResult } from './progress'; +import { createPuzzleGame, puzzleProgress } from './puzzle'; +import type { PuzzleSpec } from './puzzle'; +import { applyMoves } from './replay'; + +const PUZZLES = data as PuzzleSpec[]; + +/** Anzahl der Rätsel (Leitfaden, Website, Pressemappe) */ +export const PUZZLE_COUNT = PUZZLES.length; + +/** Rätsel der Kapitel (ohne den Vorrat der Tagesrätsel) */ +export const PUZZLE_CHAPTER_COUNT = PUZZLES.filter((p) => p.chapter > 0).length; + +/** moves = nur die eigenen Züge nach der Vorgabe (encodeMoves); hinted = Tipp genutzt → höchstens 2 Sterne (#88) */ +export function verifyPuzzle( + puzzleId: unknown, + moves: unknown, + generatorVersion: unknown, + hinted: unknown = false +): ProgressResult { + if (generatorVersion !== GENERATOR_VERSION) return { ok: false, error: 'OUTDATED_CLIENT' }; + const spec = typeof puzzleId === 'string' ? PUZZLES.find((p) => p.id === puzzleId) : undefined; + if (!spec || spec.generatorVersion !== GENERATOR_VERSION) return { ok: false, error: 'INVALID_TARGET' }; + const game = createPuzzleGame(spec, 0); + if (!applyMoves(game, moves) || game.moves.length === spec.preset.length) + return { ok: false, error: 'INVALID_MOVES' }; + const progress = puzzleProgress(spec, game, hinted === true); + return { ok: true, key: puzzleStarsKey(spec.id), stars: progress.stars, gold: game.score, lands: 0 }; +} diff --git a/src/engine/core/records.ts b/src/engine/core/records.ts new file mode 100644 index 0000000..b98674a --- /dev/null +++ b/src/engine/core/records.ts @@ -0,0 +1,145 @@ +/** + * records.ts + * Persönliche Bestwerte (Issue #67, Phase 2): nur die eigenen Daten – keine Statistik über andere. Gerechnet aus den + * besten Ergebnissen je Fortschritts-Schlüssel (Sterne, Gold, Lande), wie Server und Browser sie speichern: bestes + * Gold je Gesetz und Kartengröße, meiste Lande je Größe, Sterne je Spielart, Karten mit drei Sternen. + */ + +import { sizeFromMapId } from './Board'; +import type { MapSize } from './Board'; +import { puzzleIdOfKey } from './progress'; +import { SELECTABLE_TRAIT_IDS } from './WorldRules'; +import type { WorldTrait } from './WorldRules'; + +/** Ein bestes Ergebnis, wie es gespeichert wird (Schlüssel aus core/progress.ts) */ +export interface ProgressRow { + key: string; + stars: number; + gold: number; + lands: number; +} + +/** Bestwert samt Karte, auf der er gelang */ +export interface Best { + value: number; + mapId: string; +} + +export const RECORD_SIZES: readonly MapSize[] = [9, 12, 15]; + +export interface Records { + /** Sterne je Spielart: freie Karten (auch Tageskarte), Feldzüge, Rätsel */ + stars: { maps: number; campaign: number; puzzles: number }; + /** Freie Karten und Feldzug-Stationen mit drei Sternen */ + threeStars: number; + /** Bestes Gold je Gesetz (freie Karten) */ + goldByTrait: Partial>; + /** Bestes Gold und meiste Lande je Kartengröße (freie Karten; Schlüssel „9“, „12“, „15“) */ + goldBySize: Partial>; + landsBySize: Partial>; +} + +export const emptyRecords = (): Records => ({ + stars: { maps: 0, campaign: 0, puzzles: 0 }, + threeStars: 0, + goldByTrait: {}, + goldBySize: {}, + landsBySize: {}, +}); + +/** Freie Karte aus dem Schlüssel „Code|Gesetz|Version[|b][|f]“, sonst null */ +function mapOfKey(key: string): { mapId: string; trait: WorldTrait } | null { + if (key.startsWith('campaign:') || puzzleIdOfKey(key) !== null) return null; + const [mapId, trait] = key.split('|'); + if (!mapId || !SELECTABLE_TRAIT_IDS.includes(trait as WorldTrait)) return null; + return { mapId, trait: trait as WorldTrait }; +} + +function better(table: Partial>, key: string, value: number, mapId: string): void { + const current = table[key]; + if (!current || value > current.value) table[key] = { value, mapId }; +} + +/** Bestwerte aus den besten Ergebnissen (Reihenfolge egal) */ +export function recordsOf(rows: Iterable): Records { + const r = emptyRecords(); + for (const row of rows) { + if (row.key.startsWith('campaign:')) { + r.stars.campaign += row.stars; + if (row.stars === 3) r.threeStars++; + continue; + } + if (puzzleIdOfKey(row.key) !== null) { + r.stars.puzzles += row.stars; + continue; + } + // Herausforderung der Woche: zählt zu den Karten-Sternen + if (row.key.startsWith('weekly:')) { + r.stars.maps += row.stars; + if (row.stars === 3) r.threeStars++; + continue; + } + const map = mapOfKey(row.key); + if (!map) continue; + r.stars.maps += row.stars; + if (row.stars === 3) r.threeStars++; + const size = sizeFromMapId(map.mapId); + better(r.goldByTrait, map.trait, row.gold, map.mapId); + better(r.goldBySize, String(size), row.gold, map.mapId); + better(r.landsBySize, String(size), row.lands, map.mapId); + } + return r; +} + +/** + * Zwei Stände zusammenführen: Bestwerte das Höhere, Zähler das Größere (Gast-Speicher hält nicht jede Karte – + * gespeicherte Bestwerte dürfen dadurch nie schrumpfen). + */ +export function mergeRecords(a: Records, b: Records): Records { + const pick = (x: Partial>, y: Partial>) => { + const out: Partial> = { ...x }; + for (const [k, v] of Object.entries(y)) { + const current = out[k]; + if (v && (!current || v.value > current.value)) out[k] = v; + } + return out; + }; + return { + stars: { + maps: Math.max(a.stars.maps, b.stars.maps), + campaign: Math.max(a.stars.campaign, b.stars.campaign), + puzzles: Math.max(a.stars.puzzles, b.stars.puzzles), + }, + threeStars: Math.max(a.threeStars, b.threeStars), + goldByTrait: pick(a.goldByTrait, b.goldByTrait), + goldBySize: pick(a.goldBySize, b.goldBySize), + landsBySize: pick(a.landsBySize, b.landsBySize), + }; +} + +/** Gespeicherte Bestwerte prüfen (Browser-Speicher, Server-Antwort) – Unbrauchbares fällt weg */ +export function parseRecords(value: unknown): Records { + const r = emptyRecords(); + if (!value || typeof value !== 'object') return r; + const v = value as Record; + const num = (x: unknown) => (typeof x === 'number' && Number.isFinite(x) && x >= 0 ? Math.floor(x) : 0); + const stars = (v.stars ?? {}) as Record; + r.stars = { maps: num(stars.maps), campaign: num(stars.campaign), puzzles: num(stars.puzzles) }; + r.threeStars = num(v.threeStars); + const table = (x: unknown, keys: readonly string[]) => { + const out: Partial> = {}; + if (!x || typeof x !== 'object') return out; + for (const k of keys) { + const b = (x as Record)[k] as Record | undefined; + if (b && typeof b.mapId === 'string' && /^[0-9A-Z-]{1,16}$/.test(b.mapId)) { + out[k] = { value: num(b.value), mapId: b.mapId }; + } + } + return out; + }; + const sizes = RECORD_SIZES.map(String); + r.goldByTrait = table(v.goldByTrait, SELECTABLE_TRAIT_IDS); + r.goldBySize = table(v.goldBySize, sizes); + r.landsBySize = table(v.landsBySize, sizes); + return r; +} diff --git a/src/engine/core/renown.ts b/src/engine/core/renown.ts new file mode 100644 index 0000000..c85007b --- /dev/null +++ b/src/engine/core/renown.ts @@ -0,0 +1,65 @@ +/** + * renown.ts + * Ansehen und Herrscher-Stufe (Issue #67, Phase 1): eine lange Linie über alle Spielarten. Ansehen kommt aus dem, + * was der Server selbst geprüft hat – beste Sterne je Karte, Feldzug-Station und Rätsel, gewertete Online-Siege – + * und wächst so nur durch echtes Spielen. Stufen werden langsam teurer; je fünf Stufen ein neuer Rang + * (Knappe … Hochkönig). Nach dem letzten Rang geht es mit festen Kosten weiter. + */ + +import type { ServerFacts } from './avatars'; + +export const RENOWN_PER_STAR = 10; +export const RENOWN_PER_RATED_WIN = 20; + +export const RANKS = ['squire', 'knight', 'baron', 'count', 'duke', 'prince', 'king', 'highKing'] as const; +export type Rank = (typeof RANKS)[number]; +export const LEVELS_PER_RANK = 5; +/** Ab dieser Stufe kein neuer Rang mehr (Hochkönig), jede weitere Stufe kostet gleich viel */ +export const TOP_LEVEL = RANKS.length * LEVELS_PER_RANK; + +/** Ansehen von Stufe level nach level + 1 */ +export function levelCost(level: number): number { + return level >= TOP_LEVEL ? 1000 : 75 + 20 * (level - 1); +} + +/** Ansehen aus Summen: alle besten Sterne zusammen, gewertete Online-Siege (der Server zählt per SQL) */ +export function renownFromTotals(stars: number, ratedWins: number): number { + return stars * RENOWN_PER_STAR + ratedWins * RENOWN_PER_RATED_WIN; +} + +/** Ansehen aus den Fakten des Servers (Sterne je Fortschritts-Schlüssel, gewertete Online-Siege) */ +export function renownOf(facts: Pick): number { + return renownFromTotals( + Object.values(facts.progress).reduce((n, s) => n + s, 0), + facts.ratedWins + ); +} + +export interface Standing { + renown: number; + /** Stufe ab 1 */ + level: number; + rank: Rank; + /** Ansehen innerhalb der Stufe / nötig für die nächste */ + into: number; + next: number; +} + +/** Stufe, Rang und Fortschritt zur nächsten Stufe */ +export function standingOf(renown: number): Standing { + let level = 1; + let rest = Math.max(0, Math.floor(renown)); + while (rest >= levelCost(level)) { + rest -= levelCost(level); + level++; + } + const rank = RANKS[Math.min(RANKS.length - 1, Math.floor((level - 1) / LEVELS_PER_RANK))]; + return { renown: Math.max(0, Math.floor(renown)), level, rank, into: rest, next: levelCost(level) }; +} + +/** Ansehen, ab dem eine Stufe erreicht ist */ +export function renownForLevel(level: number): number { + let total = 0; + for (let l = 1; l < level; l++) total += levelCost(l); + return total; +} diff --git a/src/engine/core/replay.ts b/src/engine/core/replay.ts new file mode 100644 index 0000000..bcea3af --- /dev/null +++ b/src/engine/core/replay.ts @@ -0,0 +1,57 @@ +/** + * replay.ts + * Prüft eine Partie, indem sie mit derselben Engine nachgespielt wird (läuft auch auf dem Server). + * Der Server speichert nur Punkte, die er selbst nachgerechnet hat – erfundene Ergebnisse fallen auf. + */ + +import { Game } from './Game'; +import { generateBoard } from './MapGenerator'; +import { seedFromMapId } from './rng'; +import { baseMapId, sizeFromMapId } from './Board'; +import { UNIT_DEFS, UNIT_TYPES } from './units'; +import type { UnitType } from './units'; +import type { WorldTrait } from './WorldRules'; + +/** Kompakte Zugliste für die Übertragung: [Buchstabe, Feld], z. B. ['M', 40]. */ +export type EncodedMove = [letter: string, cell: number]; + +const UNIT_BY_LETTER: Record = Object.fromEntries(UNIT_TYPES.map((u) => [UNIT_DEFS[u].letter, u])); + +export function encodeMoves(moves: readonly { unit: UnitType; cell: number }[]): EncodedMove[] { + return moves.map((m) => [UNIT_DEFS[m.unit].letter, m.cell]); +} + +/** Gegenstück zu encodeMoves (für eigene, vertrauenswürdige Daten wie Rätsel); wirft bei unbekannten Zügen. */ +export function decodeMoves(moves: readonly EncodedMove[]): { unit: UnitType; cell: number }[] { + return moves.map(([letter, cell]) => { + const unit = UNIT_BY_LETTER[letter]; + if (!unit || !Number.isInteger(cell) || cell < 0 || cell >= 15 * 15) + throw new Error(`Ungültiger Zug ${letter}${cell}`); + return { unit, cell }; + }); +} + +export type ReplayResult = { ok: true; score: number; moves: number } | { ok: false; error: 'INVALID_MOVES' }; + +/** Spielt die Züge auf der Map (mapId + Welt-Regel) nach; jeder Zug muss nach den Regeln gültig sein. */ +export function replayGame(mapId: string, trait: WorldTrait, moves: unknown): ReplayResult { + // Größere Karten tragen ihre Größe im Code („…-M“, „…-L“) + const board = generateBoard(seedFromMapId(baseMapId(mapId)), { trait, size: sizeFromMapId(mapId) }); + const game = new Game(board, { undoLimit: 0 }); + if (!applyMoves(game, moves)) return { ok: false, error: 'INVALID_MOVES' }; + return { ok: true, score: game.score, moves: game.moves.length }; +} + +/** Ungeprüfte Zugliste (vom Client) auf eine Partie anwenden; false, sobald ein Zug ungültig ist. */ +export function applyMoves(game: Game, moves: unknown): boolean { + const cells = game.board.cells; + if (!Array.isArray(moves) || moves.length > cells) return false; + for (const move of moves) { + if (!Array.isArray(move) || move.length !== 2) return false; + const [letter, cell] = move; + const unit = typeof letter === 'string' ? UNIT_BY_LETTER[letter] : undefined; + if (!unit || !Number.isInteger(cell) || cell < 0 || cell >= cells) return false; + if (!game.place(unit, cell).ok) return false; + } + return true; +} diff --git a/src/engine/core/rng.ts b/src/engine/core/rng.ts new file mode 100644 index 0000000..415412d --- /dev/null +++ b/src/engine/core/rng.ts @@ -0,0 +1,97 @@ +/** + * rng.ts + * Deterministischer Zufallsgenerator (mulberry32) und Map-Codes. + * Gleicher Seed ⇒ identische Map – auf jedem Client, ohne Server. + */ + +/** Liefert Zahlen im Intervall [0, 1), wie Math.random. */ +export type Rng = () => number; + +export function mulberry32(seed: number): Rng { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** Ganzzahl im Intervall [0, n). */ +export function randInt(rng: Rng, n: number): number { + return Math.floor(rng() * n); +} + +/** Unverzerrte Fisher-Yates-Mischung; liefert eine neue Liste. */ +export function shuffle(rng: Rng, items: readonly T[]): T[] { + const out = items.slice(); + for (let i = out.length - 1; i > 0; i--) { + const j = randInt(rng, i + 1); + [out[i], out[j]] = [out[j], out[i]]; + } + return out; +} + +/** Neuer zufälliger 32-Bit-Seed (nicht deterministisch). */ +export function randomSeed(): number { + // Nur für Browser-Tests (e2e/fixtures.ts): feste, hochzählende Seeds – dieselben Karten bei jedem Lauf (Issue #32) + const test = globalThis as { __NINELANDS_TEST_SEED__?: number }; + if (typeof test.__NINELANDS_TEST_SEED__ === 'number') return test.__NINELANDS_TEST_SEED__++ >>> 0; + if (typeof crypto !== 'undefined' && crypto.getRandomValues) { + return crypto.getRandomValues(new Uint32Array(1))[0]; + } + return Math.floor(Math.random() * 0x100000000) >>> 0; +} + +/** Seed → kurzer, teilbarer Map-Code (Base36, Großbuchstaben, z. B. "1Z141Z3"). */ +export function mapCodeFromSeed(seed: number): string { + return (seed >>> 0).toString(36).toUpperCase(); +} + +const MAP_CODE_PATTERN = /^[0-9A-Z]{1,7}$/; + +/** + * Map-Code oder beliebige Map-ID → Seed. + * Gültige Codes werden exakt zurückgerechnet; alles andere (z. B. alte IDs wie "MAP-7412") + * wird per FNV-1a gehasht, damit auch alte IDs immer dieselbe Map ergeben. + */ +export function seedFromMapId(id: string): number { + const code = id.trim().toUpperCase(); + if (MAP_CODE_PATTERN.test(code)) { + const n = parseInt(code, 36); + if (n <= 0xffffffff) return n >>> 0; + } + return fnv1a(id); +} + +/** FNV-1a-Hash (32 Bit) eines Texts. */ +export function fnv1a(text: string): number { + let hash = 0x811c9dc5; + for (let i = 0; i < text.length; i++) { + hash ^= text.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + return hash >>> 0; +} + +/** Kalendertag in UTC als „JJJJ-MM-TT“ – für alle Spieler weltweit derselbe Tag. */ +export function dailyKey(date: Date): string { + return date.toISOString().slice(0, 10); +} + +/** Seed der Tageskarte: rein aus dem Datum berechnet, ohne Server. */ +export function dailySeed(date: Date): number { + return fnv1a(`hivedoku-daily-${dailyKey(date)}`); +} + +const MAP_INPUT_PATTERN = /^[0-9A-Z-]{1,16}$/; + +/** + * Eingabe eines Map-Codes normalisieren (Leerzeichen, „#“, Kleinschreibung). + * Liefert null bei ungültiger Eingabe. Alte IDs wie „MAP-7412“ bleiben erlaubt. + */ +export function parseMapCode(input: string): string | null { + const code = input.trim().replace(/^#/, '').toUpperCase(); + return MAP_INPUT_PATTERN.test(code) ? code : null; +} diff --git a/src/engine/core/server.ts b/src/engine/core/server.ts new file mode 100644 index 0000000..9609e57 --- /dev/null +++ b/src/engine/core/server.ts @@ -0,0 +1,71 @@ +/** + * server.ts + * Alles, was der Server von der Engine braucht – als eigenes Bundle gebaut (npm run build:engine → dist-server/server.js). + * Der Server führt so dieselben Regeln aus wie der Browser (Ergebnis-Prüfung, Online-Duelle). + */ + +import { Duel, DUEL_MAX_MISSED, DUEL_MOVE_MS, parseDuelVariant } from './duel'; +import { GENERATOR_VERSION } from './MapGenerator'; +import { replayGame } from './replay'; +import { cachedGoalsOf, isDailyKey, mapStarsKey, verifyProgress } from './progress'; +import { verifyConquest } from './conquest'; +import { verifyPuzzle } from './puzzleProgress'; +import { renownFromTotals, standingOf } from './renown'; +import { isBanner, isTheme, unlockedBanners, unlockedThemes } from './cosmetics'; +import { EVENT_MAPS, activeEvent, earnedEvents, eventFeat } from './events'; +import { isPlayableWeek, weeklyStarsKey } from './weekly'; +import { recordsOf } from './records'; +import { achievementProgress, achievementRenown, earnedAchievements, isFeat } from './achievements'; +import { SELECTABLE_TRAIT_IDS, WORLD_TRAIT_IDS } from './WorldRules'; +import { + AVATARS, + isAvatarId, + isFaction, + isTeamColor, + reportedByBrowser, + unlockedAvatars, + unlockedFactions, +} from './avatars'; + +/** Bild-ID, deren Aufgabe nur der Browser kennt (darf gemeldet werden) */ +const isReportedAvatar = (id: unknown) => AVATARS.some((a) => a.id === id && reportedByBrowser(a.unlock)); + +export const engine = { + GENERATOR_VERSION, + WORLD_TRAIT_IDS, + SELECTABLE_TRAIT_IDS, + replayGame, + verifyProgress, + verifyPuzzle, + isDailyKey, + mapStarsKey, + Duel, + parseDuelVariant, + DUEL_MOVE_MS, + DUEL_MAX_MISSED, + unlockedAvatars, + isAvatarId, + isTeamColor, + isReportedAvatar, + isFaction, + unlockedFactions, + renownFromTotals, + recordsOf, + earnedAchievements, + achievementRenown, + isFeat, + achievementProgress, + levelOf: (renown: number) => standingOf(renown).level, + isBanner, + isTheme, + unlockedBanners, + unlockedThemes, + EVENT_MAPS, + activeEvent, + earnedEvents, + eventFeat, + isPlayableWeek: (week: unknown) => isPlayableWeek(week), + weeklyStarsKey, + verifyConquest: (run: unknown, generatorVersion: unknown) => verifyConquest(run, generatorVersion, cachedGoalsOf), +}; +export type Engine = typeof engine; diff --git a/src/engine/core/units.ts b/src/engine/core/units.ts new file mode 100644 index 0000000..692acc7 --- /dev/null +++ b/src/engine/core/units.ts @@ -0,0 +1,176 @@ +/** + * units.ts + * Datengetriebene Definition der Schwarm-Einheiten und ihrer Zone of Influence (ZoI). + * Eine einzige Funktion (castZoI) ersetzt die vier früheren Einzel-Implementierungen. + */ + +import type { BiomeType } from './Biomes'; + +export const UNIT_TYPES = ['HIVEMOTHER', 'DRONE', 'HIVEWORKER', 'HIVESCOUT'] as const; +export type UnitType = (typeof UNIT_TYPES)[number]; + +/** Kompakte Kodierung im Spielfeld-Array (0 = leer). */ +export const UNIT_CODE: Record = { + HIVEMOTHER: 1, + DRONE: 2, + HIVEWORKER: 3, + HIVESCOUT: 4, +}; +export const UNIT_BY_CODE: readonly (UnitType | null)[] = [null, ...UNIT_TYPES]; + +/** + * Wie eine neue Einheit an das bestehende Netzwerk andocken muss: + * - 'none': gar nicht (Hivemother gründet das Netzwerk) + * - 'adjacent': eines der 8 Nachbarfelder liegt in der ZoI + * - 'zoi-touch': die eigene ZoI überlappt oder berührt (orthogonal) die bestehende ZoI + */ +export type Docking = 'none' | 'adjacent' | 'zoi-touch'; + +export interface UnitDef { + id: UnitType; + letter: string; + /** 'rays' = Strahlen mit Budget, 'ring' = die 8 Nachbarfelder */ + shape: 'rays' | 'ring'; + directions: readonly (readonly [number, number])[]; + /** ZoI-Budget pro Strahl (Feldkosten laut Biom: Ebene 1, Gebirge/Sumpf 2) */ + budget: number; + /** Darf die ZoI Zonengrenzen überschreiten? */ + crossesZones: boolean; + /** Maximale Anzahl auf dem Spielfeld */ + maxCount?: number; + /** In diesen Biomen darf die Einheit nicht gebaut werden */ + forbiddenBiomes: readonly BiomeType[]; + docking: Docking; + /** Muss das Andocken innerhalb der eigenen Zone passieren? */ + dockSameZone: boolean; + /** Ertrags-Multiplikator (Drohne 2x) */ + yieldMultiplier: number; + /** + * Stärke (nur Regeln „realms“ im 1v1): wirkt auf das eigene Feld und das beanspruchte Land. + * Ein Feld gehört dem, der dort stärker ist. + */ + strength: number; +} + +const ORTHOGONAL = [ + [1, 0], + [-1, 0], + [0, 1], + [0, -1], +] as const; + +const DIAGONAL = [ + [1, 1], + [1, -1], + [-1, 1], + [-1, -1], +] as const; + +const RING = [ + [-1, -1], + [0, -1], + [1, -1], + [-1, 0], + [1, 0], + [-1, 1], + [0, 1], + [1, 1], +] as const; + +export const UNIT_DEFS: Record = { + HIVEMOTHER: { + id: 'HIVEMOTHER', + letter: 'M', + shape: 'rays', + directions: ORTHOGONAL, + budget: 5, + crossesZones: true, + maxCount: 1, + forbiddenBiomes: ['SWAMP'], + docking: 'none', + dockSameZone: false, + yieldMultiplier: 1, + strength: 3, + }, + DRONE: { + id: 'DRONE', + letter: 'D', + shape: 'rays', + directions: ORTHOGONAL, + budget: 2, + crossesZones: false, + forbiddenBiomes: [], + docking: 'zoi-touch', + dockSameZone: true, + yieldMultiplier: 2, + strength: 2, + }, + HIVEWORKER: { + id: 'HIVEWORKER', + letter: 'W', + shape: 'ring', + directions: RING, + budget: 1, + crossesZones: false, + forbiddenBiomes: ['SWAMP'], + docking: 'adjacent', + dockSameZone: true, + yieldMultiplier: 1, + strength: 1, + }, + HIVESCOUT: { + id: 'HIVESCOUT', + letter: 'S', + shape: 'rays', + directions: DIAGONAL, + budget: 2, + crossesZones: true, + forbiddenBiomes: [], + docking: 'zoi-touch', + dockSameZone: false, + yieldMultiplier: 1, + strength: 1, + }, +}; + +/** Was castZoI über das Gelände wissen muss. */ +export interface Terrain { + readonly size: number; + readonly zone: Uint8Array; + readonly lake: Uint8Array; + zoiCostAt(i: number): number; +} + +/** + * Berechnet die Felder (Indizes y*size+x), die eine Einheit auf Feld i abdeckt. + * Hängt nur vom Gelände ab, nicht von anderen Steinen ⇒ einmal pro Map vorberechenbar. + */ +export function castZoI(def: UnitDef, t: Terrain, i: number): number[] { + const size = t.size; + const x0 = i % size; + const y0 = (i / size) | 0; + const originZone = t.zone[i]; + const out: number[] = []; + + for (const [dx, dy] of def.directions) { + let spent = 0; + for (let dist = 1; ; dist++) { + const nx = x0 + dx * dist; + const ny = y0 + dy * dist; + if (nx < 0 || nx >= size || ny < 0 || ny >= size) break; + const j = ny * size + nx; + if (!def.crossesZones && t.zone[j] !== originZone) break; + if (t.lake[j]) break; // Seen blockieren jegliche ZoI + if (def.shape === 'ring') { + out.push(j); + break; + } + const cost = t.zoiCostAt(j); + if (spent + cost > def.budget) break; + out.push(j); + spent += cost; + } + } + + return out; +} diff --git a/src/engine/core/weekly.ts b/src/engine/core/weekly.ts new file mode 100644 index 0000000..97f7258 --- /dev/null +++ b/src/engine/core/weekly.ts @@ -0,0 +1,90 @@ +/** + * weekly.ts + * Herausforderung der Woche (Issue #67, Phase 5): eine Karte je ISO-Woche (Montag bis Sonntag, UTC), für alle + * dieselbe, mit einer Wendung, die wöchentlich wechselt – größere Karte, Räuber, Nebel, gesperrte Figuren. Server und + * Browser leiten alles aus der Woche ab; der Server prüft Ergebnisse wie bei jeder Karte und führt eine Rangliste. + */ + +import type { MapSize } from './Board'; +import { GOALS_VERSION } from './goals'; +import { rolledTrait } from './MapGenerator'; +import { fnv1a } from './rng'; +import type { UnitType } from './units'; +import type { WorldTrait } from './WorldRules'; + +export interface WeeklyTwist { + id: string; + size: MapSize; + bandits: boolean; + fog: boolean; + /** Erlaubte Figuren (null = alle) */ + units: readonly UnitType[] | null; +} + +/** Wendungen der Reihe nach, eine je Woche */ +export const TWISTS: readonly WeeklyTwist[] = [ + { id: 'bandits12', size: 12, bandits: true, fog: false, units: null }, + { id: 'fogNoBishops', size: 9, bandits: false, fog: true, units: ['HIVEMOTHER', 'DRONE', 'HIVEWORKER'] }, + { id: 'large', size: 15, bandits: false, fog: false, units: null }, + { id: 'banditsFog', size: 9, bandits: true, fog: true, units: null }, + { id: 'noKnightsBandits', size: 9, bandits: true, fog: false, units: ['HIVEMOTHER', 'HIVEWORKER', 'HIVESCOUT'] }, + { id: 'fog12', size: 12, bandits: false, fog: true, units: null }, +]; + +export interface WeeklySpec extends WeeklyTwist { + week: string; + seed: number; + trait: WorldTrait; +} + +const DAY_MS = 86_400_000; +const WEEK_PATTERN = /^(\d{4})-W(\d{2})$/; + +/** ISO-Woche „2026-W43“ (UTC) */ +export function weekKey(date: Date): string { + const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); + const weekday = d.getUTCDay() || 7; + d.setUTCDate(d.getUTCDate() + 4 - weekday); // Donnerstag derselben Woche bestimmt das Jahr + const year = d.getUTCFullYear(); + const week = Math.ceil(((d.getTime() - Date.UTC(year, 0, 1)) / DAY_MS + 1) / 7); + return `${year}-W${String(week).padStart(2, '0')}`; +} + +/** Montag 00:00 UTC der Woche */ +export function weekStart(week: string): Date { + const m = WEEK_PATTERN.exec(week); + if (!m) throw new Error(`Ungültige Woche ${week}`); + const jan4 = new Date(Date.UTC(Number(m[1]), 0, 4)); + const monday = Date.UTC(Number(m[1]), 0, 4) - ((jan4.getUTCDay() || 7) - 1) * DAY_MS; + return new Date(monday + (Number(m[2]) - 1) * 7 * DAY_MS); +} + +/** Ende der Woche (nächster Montag 00:00 UTC) */ +export function weekEnd(week: string): Date { + return new Date(weekStart(week).getTime() + 7 * DAY_MS); +} + +/** Gültige Woche, nicht in der Zukunft (ältere Wochen bleiben spielbar, ihre Rangliste auch) */ +export function isPlayableWeek(week: unknown, now = new Date()): week is string { + if (typeof week !== 'string' || !WEEK_PATTERN.exec(week)) return false; + const start = weekStart(week); + return weekKey(start) === week && start.getTime() <= now.getTime(); +} + +/** Karte und Wendung einer Woche */ +export function weeklySpec(week: string): WeeklySpec { + const seed = fnv1a(`ninelands-weekly-${week}`); + const index = Math.round(weekStart(week).getTime() / (7 * DAY_MS)); + const twist = TWISTS[((index % TWISTS.length) + TWISTS.length) % TWISTS.length]; + return { ...twist, week, seed, trait: rolledTrait(seed) }; +} + +/** Schlüssel der besten Sterne einer Woche (Fortschritt, Rangliste) */ +export function weeklyStarsKey(week: string): string { + return `weekly:${week}|${GOALS_VERSION}`; +} + +/** Woche aus einem Fortschritts-Schlüssel, sonst null */ +export function weekOfKey(key: string): string | null { + return key.startsWith('weekly:') ? key.slice('weekly:'.length).split('|')[0] : null; +} diff --git a/src/engine/puzzles/puzzles.json b/src/engine/puzzles/puzzles.json new file mode 100644 index 0000000..ca8b5b3 --- /dev/null +++ b/src/engine/puzzles/puzzles.json @@ -0,0 +1,4837 @@ +[ + { + "id": "c1-01", + "chapter": 1, + "seed": 223, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1, + "preset": [ + ["M", 59], + ["D", 46], + ["W", 31], + ["W", 49], + ["W", 52], + ["W", 34], + ["W", 76], + ["W", 48], + ["W", 65], + ["W", 63], + ["W", 69], + ["W", 80], + ["S", 13], + ["W", 24], + ["W", 6], + ["W", 4], + ["W", 17], + ["W", 43] + ], + "moves": 3, + "units": null, + "goal": "energy", + "stars": [100, 104, 106], + "par": 106, + "solution": [ + ["W", 2], + ["W", 0], + ["W", 18] + ] + }, + { + "id": "c1-02", + "chapter": 1, + "seed": 229, + "trait": "DOUBLE_DRONE_COST", + "generatorVersion": 1, + "preset": [ + ["M", 45], + ["D", 39], + ["W", 37], + ["W", 55], + ["W", 57], + ["W", 18], + ["W", 1], + ["S", 2], + ["W", 13], + ["W", 31], + ["W", 20] + ], + "moves": 3, + "units": null, + "goal": "energy", + "stars": [80, 85, 87], + "par": 87, + "solution": [ + ["W", 38], + ["S", 43], + ["D", 71] + ] + }, + { + "id": "c1-03", + "chapter": 1, + "seed": 50, + "trait": "DOUBLE_DRONE_COST", + "generatorVersion": 1, + "preset": [ + ["M", 57], + ["D", 73], + ["W", 31], + ["W", 50], + ["W", 43], + ["W", 47], + ["W", 23], + ["W", 49], + ["W", 52], + ["W", 45], + ["W", 28], + ["W", 69], + ["W", 71], + ["S", 62], + ["W", 67], + ["W", 78] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [130, 134, 135], + "par": 135, + "solution": [ + ["S", 1], + ["W", 0], + ["W", 2], + ["W", 18] + ] + }, + { + "id": "c1-04", + "chapter": 1, + "seed": 108, + "trait": "DOUBLE_DRONE_COST", + "generatorVersion": 1, + "preset": [ + ["M", 37], + ["W", 31], + ["W", 13], + ["W", 42], + ["W", 49], + ["W", 47], + ["W", 56], + ["W", 74], + ["S", 76], + ["W", 54], + ["W", 72], + ["S", 2], + ["W", 27], + ["W", 45], + ["W", 67], + ["W", 51], + ["W", 78], + ["W", 30] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [87, 91, 92], + "par": 92, + "solution": [ + ["S", 6], + ["W", 7], + ["W", 24], + ["W", 25] + ] + }, + { + "id": "c1-05", + "chapter": 1, + "seed": 186, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1, + "preset": [["M", 50]], + "moves": 3, + "units": null, + "goal": "energy", + "stars": [44, 54, 60], + "par": 60, + "solution": [ + ["D", 57], + ["W", 33], + ["W", 26] + ] + }, + { + "id": "c1-06", + "chapter": 1, + "seed": 171, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1, + "preset": [ + ["M", 35], + ["W", 23], + ["W", 40], + ["W", 57], + ["W", 7], + ["W", 5], + ["W", 12], + ["W", 42], + ["W", 61], + ["W", 80], + ["W", 25], + ["W", 43] + ], + "moves": 3, + "units": null, + "goal": "resources", + "stars": [3, 3, 2], + "par": 2, + "solution": [ + ["S", 37], + ["S", 77] + ] + }, + { + "id": "c1-07", + "chapter": 1, + "seed": 34, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1, + "preset": [ + ["M", 39], + ["W", 29], + ["W", 31], + ["W", 51], + ["W", 49], + ["W", 9], + ["W", 2], + ["W", 74], + ["W", 72], + ["W", 55], + ["W", 47], + ["W", 65], + ["W", 45], + ["W", 27] + ], + "moves": 4, + "units": null, + "goal": "resources", + "stars": [4, 4, 3], + "par": 3, + "solution": [ + ["S", 15], + ["S", 33], + ["S", 62] + ] + }, + { + "id": "c1-08", + "chapter": 1, + "seed": 83, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1, + "preset": [ + ["M", 77], + ["W", 24], + ["W", 31], + ["W", 58], + ["W", 13], + ["W", 40], + ["W", 60], + ["W", 64], + ["W", 45], + ["W", 71], + ["W", 67], + ["W", 56] + ], + "moves": 4, + "units": null, + "goal": "resources", + "stars": [4, 4, 3], + "par": 3, + "solution": [ + ["S", 15], + ["S", 16], + ["D", 35] + ] + }, + { + "id": "c2-01", + "chapter": 2, + "seed": 19, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "preset": [ + ["M", 7], + ["W", 26], + ["W", 33], + ["W", 31], + ["W", 51], + ["W", 14], + ["W", 44], + ["W", 62], + ["S", 49], + ["W", 68], + ["W", 66], + ["W", 74], + ["W", 72], + ["W", 55], + ["W", 29], + ["W", 36], + ["W", 47] + ], + "moves": 3, + "units": null, + "goal": "energy", + "stars": [81, 86, 89], + "par": 89, + "solution": [ + ["S", 12], + ["D", 24], + ["W", 1] + ] + }, + { + "id": "c2-02", + "chapter": 2, + "seed": 87, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "preset": [ + ["M", 14], + ["W", 4], + ["W", 51], + ["W", 58], + ["W", 21], + ["W", 6], + ["W", 69], + ["W", 49], + ["W", 71], + ["W", 22], + ["W", 20], + ["W", 66], + ["W", 64], + ["W", 26], + ["D", 24], + ["W", 7], + ["S", 35], + ["W", 34], + ["W", 52] + ], + "moves": 3, + "units": null, + "goal": "energy", + "stars": [135, 140, 143], + "par": 143, + "solution": [ + ["S", 19], + ["D", 3], + ["W", 28] + ] + }, + { + "id": "c2-03", + "chapter": 2, + "seed": 202, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "preset": [ + ["M", 7], + ["W", 13], + ["W", 17], + ["W", 33], + ["W", 15], + ["W", 51], + ["W", 26], + ["W", 44], + ["W", 12], + ["W", 19], + ["W", 1] + ], + "moves": 3, + "units": null, + "goal": "energy", + "stars": [64, 69, 72], + "par": 72, + "solution": [ + ["S", 45], + ["D", 63], + ["W", 56] + ] + }, + { + "id": "c2-04", + "chapter": 2, + "seed": 137, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1, + "preset": [ + ["M", 14], + ["D", 9], + ["W", 40], + ["W", 51], + ["W", 24], + ["W", 3], + ["W", 21], + ["W", 2], + ["W", 6], + ["W", 26], + ["W", 8], + ["W", 20], + ["W", 27], + ["W", 45] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [88, 92, 94], + "par": 94, + "solution": [ + ["S", 64], + ["D", 65], + ["W", 57], + ["W", 59] + ] + }, + { + "id": "c2-05", + "chapter": 2, + "seed": 65, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "preset": [ + ["M", 2], + ["W", 19], + ["W", 14], + ["W", 12], + ["W", 37], + ["D", 56], + ["W", 16], + ["W", 32], + ["W", 34], + ["S", 8], + ["S", 9], + ["S", 17], + ["S", 27], + ["S", 26], + ["S", 10] + ], + "moves": 5, + "units": null, + "goal": "energy", + "stars": [32, 41, 46], + "par": 46, + "solution": [ + ["W", 28], + ["S", 40], + ["W", 59], + ["W", 61], + ["D", 58] + ] + }, + { + "id": "c2-06", + "chapter": 2, + "seed": 167, + "trait": "CHEAP_DRONES", + "generatorVersion": 1, + "preset": [ + ["M", 29], + ["D", 21], + ["D", 58], + ["D", 39], + ["W", 32], + ["W", 37], + ["W", 19], + ["S", 3], + ["D", 33], + ["D", 22], + ["D", 14], + ["D", 2], + ["D", 0], + ["D", 10], + ["S", 8], + ["D", 6], + ["D", 17], + ["D", 34] + ], + "moves": 3, + "units": null, + "goal": "energy", + "stars": [125, 134, 139], + "par": 139, + "solution": [ + ["S", 62], + ["D", 43], + ["D", 80] + ] + }, + { + "id": "c2-07", + "chapter": 2, + "seed": 170, + "trait": "CHEAP_DRONES", + "generatorVersion": 1, + "preset": [ + ["M", 25], + ["D", 33], + ["D", 44], + ["D", 12], + ["D", 31], + ["D", 4], + ["S", 80], + ["W", 6], + ["W", 8], + ["D", 51], + ["D", 69], + ["D", 79], + ["D", 71] + ], + "moves": 3, + "units": null, + "goal": "energy", + "stars": [60, 69, 74], + "par": 74, + "solution": [ + ["S", 36], + ["D", 21], + ["D", 64] + ] + }, + { + "id": "c2-08", + "chapter": 2, + "seed": 192, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "preset": [ + ["M", 25], + ["W", 17], + ["W", 6], + ["W", 33], + ["W", 60], + ["W", 35], + ["W", 53], + ["W", 79] + ], + "moves": 3, + "units": null, + "goal": "energy", + "stars": [73, 79, 83], + "par": 83, + "solution": [ + ["D", 5], + ["D", 12], + ["W", 62] + ] + }, + { + "id": "c3-01", + "chapter": 3, + "seed": 26, + "trait": "DOUBLE_DRONE_COST", + "generatorVersion": 1, + "preset": [ + ["M", 4], + ["D", 7], + ["W", 49], + ["W", 10], + ["W", 21], + ["S", 76], + ["W", 51], + ["W", 69], + ["W", 67], + ["W", 14], + ["W", 12], + ["W", 32], + ["W", 50], + ["W", 47], + ["W", 30], + ["S", 18], + ["W", 19], + ["W", 37] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [88, 92, 93], + "par": 93, + "solution": [ + ["S", 43], + ["W", 44], + ["W", 25], + ["W", 42] + ] + }, + { + "id": "c3-02", + "chapter": 3, + "seed": 119, + "trait": "CHEAP_DRONES", + "generatorVersion": 1, + "preset": [ + ["M", 60], + ["D", 48], + ["D", 22], + ["D", 43], + ["D", 53], + ["D", 12], + ["W", 32], + ["D", 41], + ["D", 49], + ["D", 68], + ["W", 76], + ["S", 54], + ["D", 29], + ["D", 45], + ["D", 37], + ["W", 47], + ["W", 63], + ["W", 65], + ["W", 71] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [142, 150, 154], + "par": 154, + "solution": [ + ["S", 20], + ["D", 0], + ["D", 5], + ["D", 13] + ] + }, + { + "id": "c3-03", + "chapter": 3, + "seed": 105, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "preset": [["M", 16]], + "moves": 5, + "units": null, + "goal": "energy", + "stars": [48, 64, 74], + "par": 74, + "solution": [ + ["W", 3], + ["W", 6], + ["D", 22], + ["D", 44], + ["W", 26] + ] + }, + { + "id": "c3-04", + "chapter": 3, + "seed": 41, + "trait": "CHEAP_DRONES", + "generatorVersion": 1, + "preset": [ + ["M", 71], + ["D", 15], + ["D", 66], + ["D", 58], + ["D", 74], + ["D", 25], + ["D", 8], + ["D", 42], + ["D", 32], + ["S", 30], + ["D", 5], + ["D", 21], + ["D", 13], + ["D", 11], + ["D", 19], + ["D", 48], + ["D", 0], + ["D", 61], + ["W", 41] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [166, 174, 178], + "par": 178, + "solution": [ + ["D", 59], + ["W", 77], + ["S", 56], + ["D", 63] + ] + }, + { + "id": "c3-05", + "chapter": 3, + "seed": 9, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "preset": [ + ["M", 7], + ["W", 17], + ["W", 24], + ["W", 62], + ["W", 26], + ["W", 44], + ["W", 13], + ["W", 32], + ["S", 38], + ["W", 37], + ["W", 39], + ["W", 21], + ["W", 27] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [105, 117, 123], + "par": 123, + "solution": [ + ["S", 64], + ["D", 36], + ["D", 65], + ["W", 57] + ] + }, + { + "id": "c3-06", + "chapter": 3, + "seed": 79, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "preset": [ + ["M", 4], + ["W", 14], + ["W", 16], + ["W", 48], + ["W", 50], + ["W", 66], + ["W", 12], + ["W", 32], + ["S", 46], + ["W", 27], + ["W", 9], + ["W", 45], + ["W", 55], + ["W", 75], + ["W", 73], + ["S", 79], + ["W", 80], + ["W", 62], + ["W", 44], + ["W", 51], + ["W", 78] + ], + "moves": 5, + "units": ["HIVEMOTHER", "DRONE"], + "goal": "energy", + "stars": [104, 109, 112], + "par": 112, + "solution": [ + ["D", 29], + ["D", 11] + ] + }, + { + "id": "c3-07", + "chapter": 3, + "seed": 158, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "preset": [ + ["M", 21], + ["W", 29], + ["W", 11], + ["W", 4], + ["W", 31], + ["W", 28], + ["W", 45], + ["W", 47], + ["W", 2], + ["D", 36], + ["W", 0], + ["W", 26], + ["W", 8], + ["W", 6] + ], + "moves": 5, + "units": ["HIVEMOTHER", "DRONE", "HIVEWORKER"], + "goal": "energy", + "stars": [92, 97, 100], + "par": 100, + "solution": [ + ["D", 55], + ["D", 65] + ] + }, + { + "id": "c3-08", + "chapter": 3, + "seed": 135, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "preset": [ + ["M", 1], + ["W", 13], + ["W", 7], + ["W", 11], + ["W", 38], + ["W", 18], + ["W", 56], + ["W", 45], + ["W", 27], + ["S", 40], + ["W", 41], + ["W", 49], + ["W", 31], + ["W", 43] + ], + "moves": 3, + "units": null, + "goal": "energy", + "stars": [97, 102, 105], + "par": 105, + "solution": [ + ["S", 58], + ["D", 74], + ["W", 55] + ] + }, + { + "id": "d-001", + "chapter": 0, + "seed": 4, + "trait": "CHEAP_DRONES", + "generatorVersion": 1, + "preset": [ + ["M", 40], + ["D", 23], + ["D", 29], + ["D", 32], + ["D", 33], + ["D", 68], + ["D", 78], + ["D", 52], + ["W", 75], + ["W", 34], + ["S", 18], + ["W", 48], + ["W", 59], + ["W", 21], + ["D", 9], + ["D", 19], + ["D", 1] + ], + "moves": 3, + "units": null, + "goal": "energy", + "stars": [99, 104, 107], + "par": 107, + "solution": [ + ["S", 6], + ["D", 7] + ] + }, + { + "id": "d-002", + "chapter": 0, + "seed": 7, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "preset": [ + ["M", 10], + ["W", 27], + ["W", 20], + ["W", 5], + ["D", 2], + ["W", 38], + ["D", 40], + ["W", 36], + ["S", 31], + ["W", 43], + ["W", 50], + ["W", 62], + ["W", 22], + ["W", 42], + ["S", 60], + ["W", 61], + ["W", 77] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [131, 137, 141], + "par": 141, + "solution": [ + ["W", 79], + ["S", 65], + ["D", 56], + ["W", 48] + ] + }, + { + "id": "d-003", + "chapter": 0, + "seed": 8, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "preset": [ + ["M", 15], + ["W", 43], + ["W", 62], + ["W", 26], + ["W", 44], + ["W", 50], + ["W", 68], + ["W", 79], + ["W", 22], + ["D", 41], + ["W", 5], + ["W", 7] + ], + "moves": 3, + "units": null, + "goal": "energy", + "stars": [94, 99, 102], + "par": 102, + "solution": [ + ["D", 12], + ["S", 10], + ["W", 19] + ] + }, + { + "id": "d-004", + "chapter": 0, + "seed": 14, + "trait": "DOUBLE_DRONE_COST", + "generatorVersion": 1, + "preset": [ + ["M", 73], + ["W", 37], + ["W", 30], + ["W", 56] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [49, 55, 58], + "par": 58, + "solution": [ + ["S", 20], + ["W", 50], + ["S", 78], + ["D", 71] + ] + }, + { + "id": "d-005", + "chapter": 0, + "seed": 17, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1, + "preset": [ + ["M", 30], + ["D", 18], + ["W", 4], + ["W", 11], + ["W", 58], + ["W", 51], + ["W", 76], + ["W", 47], + ["W", 45], + ["W", 75], + ["W", 56], + ["W", 54], + ["W", 72], + ["W", 22], + ["W", 49] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [132, 137, 140], + "par": 140, + "solution": [ + ["S", 53], + ["D", 71], + ["W", 23], + ["W", 34] + ] + }, + { + "id": "d-006", + "chapter": 0, + "seed": 25, + "trait": "DOUBLE_DRONE_COST", + "generatorVersion": 1, + "preset": [ + ["M", 16], + ["W", 23], + ["W", 4], + ["W", 3], + ["W", 42], + ["W", 53], + ["W", 35], + ["W", 1], + ["W", 18], + ["W", 26], + ["W", 24], + ["W", 8], + ["W", 6] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [85, 94, 99], + "par": 99, + "solution": [ + ["S", 68], + ["D", 56], + ["D", 75], + ["W", 51] + ] + }, + { + "id": "d-007", + "chapter": 0, + "seed": 32, + "trait": "SINGLE_WORKER_PER_ROOM", + "generatorVersion": 1, + "preset": [ + ["M", 15], + ["W", 19], + ["W", 21], + ["W", 50], + ["S", 28], + ["W", 32], + ["D", 3], + ["D", 0], + ["D", 4], + ["D", 8], + ["D", 52], + ["D", 69], + ["D", 79], + ["W", 23], + ["D", 43], + ["D", 35], + ["D", 71], + ["S", 31], + ["W", 30], + ["D", 48], + ["D", 58] + ], + "moves": 4, + "units": ["HIVEMOTHER", "HIVEWORKER", "HIVESCOUT"], + "goal": "energy", + "stars": [71, 75, 77], + "par": 77, + "solution": [ + ["S", 56], + ["W", 57], + ["W", 72] + ] + }, + { + "id": "d-008", + "chapter": 0, + "seed": 37, + "trait": "DOUBLE_DRONE_COST", + "generatorVersion": 1, + "preset": [ + ["M", 48], + ["W", 56], + ["W", 59], + ["W", 61], + ["W", 80], + ["S", 34], + ["W", 15], + ["W", 4], + ["W", 2], + ["W", 38], + ["W", 36], + ["W", 11], + ["W", 28], + ["W", 9], + ["W", 32], + ["W", 25], + ["W", 6], + ["W", 8], + ["W", 35], + ["W", 52] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [137, 141, 142], + "par": 142, + "solution": [ + ["S", 45], + ["W", 54], + ["W", 72], + ["W", 74] + ] + }, + { + "id": "d-009", + "chapter": 0, + "seed": 38, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1, + "preset": [ + ["M", 44], + ["W", 25], + ["W", 40], + ["W", 6], + ["W", 61], + ["W", 59], + ["W", 51], + ["W", 33] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [68, 73, 76], + "par": 76, + "solution": [ + ["S", 55], + ["D", 54], + ["W", 36], + ["W", 29] + ] + }, + { + "id": "d-010", + "chapter": 0, + "seed": 39, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "preset": [ + ["M", 14], + ["W", 24], + ["W", 31], + ["W", 29], + ["W", 6], + ["W", 17], + ["W", 49], + ["W", 51], + ["W", 58], + ["W", 74], + ["W", 76], + ["W", 20], + ["D", 3], + ["W", 1], + ["S", 60], + ["W", 78], + ["W", 61] + ], + "moves": 5, + "units": null, + "goal": "resources", + "stars": [5, 5, 4], + "par": 4, + "solution": [ + ["S", 10], + ["S", 25], + ["D", 34], + ["S", 45] + ] + }, + { + "id": "d-011", + "chapter": 0, + "seed": 40, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1, + "preset": [ + ["M", 5], + ["W", 15], + ["W", 26], + ["W", 33], + ["W", 40], + ["W", 22], + ["W", 0], + ["W", 20], + ["W", 24], + ["W", 12], + ["S", 36], + ["W", 38], + ["W", 45], + ["W", 27] + ], + "moves": 3, + "units": null, + "goal": "energy", + "stars": [68, 71, 73], + "par": 73, + "solution": [ + ["S", 57], + ["D", 74], + ["W", 58] + ] + }, + { + "id": "d-012", + "chapter": 0, + "seed": 43, + "trait": "CHEAP_DRONES", + "generatorVersion": 1, + "preset": [ + ["M", 9], + ["D", 14], + ["D", 21], + ["S", 36], + ["D", 4], + ["W", 1], + ["D", 45], + ["S", 30], + ["D", 31], + ["D", 39], + ["D", 60], + ["D", 47], + ["D", 71], + ["D", 68], + ["D", 79], + ["D", 19], + ["D", 37], + ["S", 6], + ["D", 8], + ["D", 15] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [121, 133, 139], + "par": 139, + "solution": [ + ["S", 56], + ["D", 57], + ["D", 65], + ["D", 72] + ] + }, + { + "id": "d-013", + "chapter": 0, + "seed": 46, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "preset": [ + ["M", 14], + ["W", 26], + ["W", 24], + ["W", 40], + ["W", 8], + ["W", 51], + ["W", 31], + ["W", 58], + ["W", 44], + ["W", 62], + ["W", 6], + ["W", 25], + ["W", 43], + ["W", 61], + ["S", 66], + ["W", 75], + ["W", 57], + ["W", 68], + ["W", 47], + ["W", 65] + ], + "moves": 5, + "units": null, + "goal": "resources", + "stars": [5, 5, 4], + "par": 4, + "solution": [ + ["D", 3], + ["D", 0], + ["S", 10], + ["D", 21] + ] + }, + { + "id": "d-014", + "chapter": 0, + "seed": 53, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "preset": [ + ["M", 4], + ["W", 14], + ["W", 32], + ["W", 11], + ["W", 9], + ["W", 15], + ["D", 17], + ["W", 30], + ["D", 48], + ["W", 56], + ["W", 35], + ["W", 12] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [110, 117, 120], + "par": 120, + "solution": [ + ["S", 64], + ["D", 73], + ["W", 45], + ["W", 27] + ] + }, + { + "id": "d-015", + "chapter": 0, + "seed": 57, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1, + "preset": [["M", 32]], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [40, 48, 52], + "par": 52, + "solution": [ + ["W", 4], + ["W", 38], + ["S", 65], + ["D", 63] + ] + }, + { + "id": "d-016", + "chapter": 0, + "seed": 61, + "trait": "DOUBLE_DRONE_COST", + "generatorVersion": 1, + "preset": [ + ["M", 57], + ["W", 49], + ["W", 31], + ["W", 51], + ["W", 29], + ["W", 47], + ["W", 63], + ["W", 65], + ["S", 60], + ["W", 20], + ["W", 22], + ["S", 7], + ["W", 41], + ["W", 24], + ["W", 45], + ["W", 27], + ["W", 10], + ["W", 26], + ["W", 67], + ["W", 8], + ["W", 6] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [122, 126, 127], + "par": 127, + "solution": [ + ["S", 1], + ["W", 0], + ["W", 2], + ["W", 18] + ] + }, + { + "id": "d-017", + "chapter": 0, + "seed": 66, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "preset": [ + ["M", 1], + ["W", 14] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [41, 53, 59], + "par": 59, + "solution": [ + ["W", 18], + ["D", 45], + ["S", 7], + ["D", 16] + ] + }, + { + "id": "d-018", + "chapter": 0, + "seed": 67, + "trait": "CHEAP_DRONES", + "generatorVersion": 1, + "preset": [ + ["M", 4], + ["D", 32], + ["S", 44], + ["D", 50], + ["D", 62], + ["D", 51], + ["D", 61], + ["D", 69], + ["S", 80], + ["W", 23], + ["S", 12], + ["D", 46], + ["D", 56], + ["D", 29], + ["D", 40], + ["D", 48] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [133, 138, 140], + "par": 140, + "solution": [ + ["S", 0], + ["D", 1], + ["D", 11], + ["D", 18] + ] + }, + { + "id": "d-019", + "chapter": 0, + "seed": 72, + "trait": "SINGLE_WORKER_PER_ROOM", + "generatorVersion": 1, + "preset": [ + ["M", 5], + ["W", 31], + ["S", 10], + ["W", 33], + ["D", 16], + ["D", 24], + ["D", 52], + ["D", 26], + ["W", 12], + ["S", 37], + ["W", 48], + ["D", 50], + ["D", 54], + ["D", 46], + ["S", 35], + ["S", 39], + ["S", 40], + ["W", 66] + ], + "moves": 3, + "units": ["HIVEMOTHER", "HIVEWORKER", "HIVESCOUT"], + "goal": "energy", + "stars": [37, 42, 44], + "par": 44, + "solution": [ + ["S", 70], + ["W", 53], + ["W", 79] + ] + }, + { + "id": "d-020", + "chapter": 0, + "seed": 77, + "trait": "DOUBLE_DRONE_COST", + "generatorVersion": 1, + "preset": [ + ["M", 48], + ["W", 37], + ["W", 56], + ["W", 74], + ["W", 20], + ["W", 54], + ["W", 72], + ["W", 40], + ["S", 4], + ["W", 15], + ["W", 23], + ["W", 22], + ["W", 3], + ["W", 11], + ["W", 0] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [100, 105, 107], + "par": 107, + "solution": [ + ["W", 16], + ["W", 18], + ["S", 59], + ["D", 71] + ] + }, + { + "id": "d-021", + "chapter": 0, + "seed": 80, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1, + "preset": [ + ["M", 65], + ["W", 39], + ["W", 41], + ["W", 19], + ["W", 58], + ["W", 3], + ["W", 1], + ["W", 5], + ["W", 75], + ["W", 77], + ["W", 54], + ["W", 73] + ], + "moves": 3, + "units": null, + "goal": "energy", + "stars": [87, 95, 99], + "par": 99, + "solution": [ + ["S", 15], + ["D", 6], + ["D", 31] + ] + }, + { + "id": "d-022", + "chapter": 0, + "seed": 82, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1, + "preset": [ + ["M", 27], + ["W", 32], + ["W", 49], + ["W", 20], + ["W", 72], + ["W", 37], + ["W", 38], + ["W", 56], + ["W", 74], + ["W", 76], + ["W", 19], + ["W", 0], + ["W", 2] + ], + "moves": 5, + "units": ["HIVEMOTHER", "DRONE", "HIVEWORKER"], + "goal": "energy", + "stars": [95, 100, 103], + "par": 103, + "solution": [ + ["D", 12], + ["D", 23] + ] + }, + { + "id": "d-023", + "chapter": 0, + "seed": 85, + "trait": "DOUBLE_DRONE_COST", + "generatorVersion": 1, + "preset": [ + ["M", 48], + ["W", 36], + ["W", 18], + ["W", 38], + ["W", 42], + ["W", 54], + ["W", 44], + ["W", 56], + ["W", 61], + ["W", 58], + ["W", 77], + ["W", 74], + ["W", 72], + ["W", 55], + ["W", 40] + ], + "moves": 3, + "units": null, + "goal": "energy", + "stars": [117, 122, 124], + "par": 124, + "solution": [ + ["S", 13], + ["D", 1], + ["W", 31] + ] + }, + { + "id": "d-024", + "chapter": 0, + "seed": 86, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "preset": [ + ["M", 14], + ["W", 26], + ["W", 44], + ["W", 8], + ["W", 42], + ["W", 6], + ["W", 21], + ["W", 39], + ["W", 3], + ["W", 2], + ["D", 9], + ["W", 19] + ], + "moves": 5, + "units": null, + "goal": "resources", + "stars": [5, 5, 4], + "par": 4, + "solution": [ + ["D", 33], + ["D", 40], + ["S", 50], + ["D", 70] + ] + }, + { + "id": "d-025", + "chapter": 0, + "seed": 88, + "trait": "CHEAP_DRONES", + "generatorVersion": 1, + "preset": [ + ["M", 58], + ["D", 21], + ["D", 32], + ["D", 50], + ["D", 52], + ["D", 69], + ["D", 79], + ["D", 4], + ["D", 7], + ["D", 12], + ["D", 74], + ["D", 64], + ["S", 16], + ["D", 25], + ["D", 45], + ["W", 17], + ["S", 11], + ["W", 35], + ["D", 53], + ["W", 0], + ["W", 68], + ["W", 75] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [146, 151, 153], + "par": 153, + "solution": [ + ["S", 27], + ["D", 18], + ["D", 38], + ["D", 46] + ] + }, + { + "id": "d-026", + "chapter": 0, + "seed": 90, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "preset": [ + ["M", 15], + ["W", 26], + ["W", 41], + ["W", 48], + ["W", 30], + ["W", 7], + ["W", 3], + ["W", 1], + ["W", 18], + ["W", 34], + ["W", 52], + ["W", 21], + ["W", 4], + ["W", 23] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [94, 106, 112], + "par": 112, + "solution": [ + ["S", 37], + ["D", 36], + ["W", 46], + ["D", 65] + ] + }, + { + "id": "d-027", + "chapter": 0, + "seed": 91, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1, + "preset": [ + ["M", 25], + ["W", 32], + ["W", 31], + ["W", 13], + ["W", 20], + ["W", 48], + ["W", 50], + ["W", 35], + ["W", 53], + ["W", 51], + ["W", 33], + ["W", 17], + ["W", 6], + ["D", 60], + ["S", 76], + ["W", 78], + ["W", 58], + ["D", 62] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [62, 70, 74], + "par": 74, + "solution": [["D", 80]] + }, + { + "id": "d-028", + "chapter": 0, + "seed": 95, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1, + "preset": [ + ["M", 22], + ["W", 41], + ["W", 5], + ["W", 48], + ["W", 43], + ["W", 12], + ["W", 27], + ["W", 10], + ["W", 14], + ["W", 30], + ["W", 32], + ["W", 34], + ["D", 15], + ["D", 8] + ], + "moves": 3, + "units": null, + "goal": "energy", + "stars": [94, 97, 99], + "par": 99, + "solution": [ + ["S", 57], + ["D", 75], + ["W", 28] + ] + }, + { + "id": "d-029", + "chapter": 0, + "seed": 96, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "preset": [ + ["M", 3], + ["W", 15], + ["W", 17], + ["W", 11], + ["W", 18], + ["W", 40], + ["W", 22], + ["W", 24] + ], + "moves": 4, + "units": ["HIVEMOTHER", "HIVEWORKER", "HIVESCOUT"], + "goal": "energy", + "stars": [68, 73, 75], + "par": 75, + "solution": [ + ["S", 43], + ["W", 34], + ["W", 60], + ["W", 71] + ] + }, + { + "id": "d-030", + "chapter": 0, + "seed": 97, + "trait": "DOUBLE_DRONE_COST", + "generatorVersion": 1, + "preset": [ + ["M", 33], + ["W", 19], + ["W", 36], + ["W", 38], + ["W", 55], + ["W", 49], + ["W", 7], + ["W", 26], + ["W", 23], + ["W", 5], + ["W", 3], + ["W", 1], + ["W", 60], + ["S", 80], + ["W", 62], + ["W", 44] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [106, 111, 113], + "par": 113, + "solution": [ + ["S", 65], + ["D", 72], + ["W", 67], + ["W", 50] + ] + }, + { + "id": "d-031", + "chapter": 0, + "seed": 99, + "trait": "SINGLE_WORKER_PER_ROOM", + "generatorVersion": 1, + "preset": [ + ["M", 27], + ["W", 40], + ["D", 57], + ["D", 21], + ["W", 20], + ["W", 19], + ["D", 3], + ["S", 50], + ["W", 51], + ["D", 69], + ["D", 79], + ["S", 33], + ["D", 35], + ["W", 26], + ["W", 22], + ["D", 24], + ["D", 8], + ["D", 15], + ["D", 5] + ], + "moves": 3, + "units": null, + "goal": "energy", + "stars": [103, 108, 111], + "par": 111, + "solution": [ + ["S", 56], + ["D", 72], + ["W", 67] + ] + }, + { + "id": "d-032", + "chapter": 0, + "seed": 104, + "trait": "CHEAP_DRONES", + "generatorVersion": 1, + "preset": [ + ["M", 4], + ["D", 30], + ["D", 14], + ["W", 10], + ["D", 39], + ["D", 32], + ["W", 66], + ["D", 9], + ["D", 28], + ["S", 27], + ["D", 20], + ["W", 15], + ["W", 8], + ["W", 26], + ["D", 46], + ["S", 72], + ["W", 54], + ["W", 56], + ["W", 75] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [92, 97, 99], + "par": 99, + "solution": [ + ["S", 24], + ["D", 25], + ["D", 33], + ["D", 53] + ] + }, + { + "id": "d-033", + "chapter": 0, + "seed": 107, + "trait": "CHEAP_DRONES", + "generatorVersion": 1, + "preset": [ + ["M", 23], + ["D", 31], + ["D", 33], + ["D", 35], + ["D", 50], + ["D", 51], + ["D", 43], + ["D", 17], + ["D", 6], + ["S", 47], + ["D", 36], + ["D", 66], + ["D", 76], + ["D", 28], + ["S", 78], + ["W", 20], + ["W", 62], + ["W", 77], + ["W", 79] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [150, 155, 157], + "par": 157, + "solution": [ + ["S", 1], + ["D", 2], + ["D", 10], + ["D", 18] + ] + }, + { + "id": "d-034", + "chapter": 0, + "seed": 109, + "trait": "CHEAP_DRONES", + "generatorVersion": 1, + "preset": [ + ["M", 70], + ["D", 77], + ["D", 73], + ["D", 41], + ["D", 55], + ["D", 45], + ["D", 72], + ["D", 53], + ["S", 27], + ["D", 10], + ["D", 0], + ["D", 3], + ["D", 20], + ["D", 51], + ["S", 21], + ["W", 28], + ["W", 62], + ["W", 78], + ["W", 80], + ["W", 30], + ["W", 47] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [126, 131, 133], + "par": 133, + "solution": [ + ["S", 14], + ["D", 15], + ["D", 25], + ["D", 35] + ] + }, + { + "id": "d-035", + "chapter": 0, + "seed": 110, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "preset": [ + ["M", 12], + ["W", 20], + ["W", 18], + ["W", 65], + ["W", 54], + ["W", 36], + ["W", 38], + ["W", 4], + ["W", 2], + ["W", 0] + ], + "moves": 3, + "units": null, + "goal": "energy", + "stars": [83, 88, 91], + "par": 91, + "solution": [ + ["S", 23], + ["D", 42], + ["W", 6] + ] + }, + { + "id": "d-036", + "chapter": 0, + "seed": 111, + "trait": "CHEAP_DRONES", + "generatorVersion": 1, + "preset": [ + ["M", 28], + ["D", 12], + ["D", 22], + ["W", 65], + ["D", 67], + ["D", 63], + ["D", 3], + ["D", 13], + ["D", 36], + ["D", 47], + ["W", 0], + ["W", 18], + ["W", 40] + ], + "moves": 4, + "units": ["HIVEMOTHER", "HIVESCOUT"], + "goal": "resources", + "stars": [4, 4, 3], + "par": 3, + "solution": [ + ["S", 6], + ["S", 7], + ["S", 52] + ] + }, + { + "id": "d-037", + "chapter": 0, + "seed": 117, + "trait": "SINGLE_WORKER_PER_ROOM", + "generatorVersion": 1, + "preset": [ + ["M", 65], + ["W", 57], + ["W", 61], + ["D", 73], + ["D", 76], + ["W", 28], + ["D", 79], + ["D", 46], + ["W", 72], + ["S", 41], + ["W", 39], + ["W", 32], + ["W", 15], + ["D", 59], + ["D", 34], + ["D", 17] + ], + "moves": 3, + "units": null, + "goal": "energy", + "stars": [91, 103, 109], + "par": 109, + "solution": [ + ["S", 13], + ["D", 1], + ["D", 4] + ] + }, + { + "id": "d-038", + "chapter": 0, + "seed": 118, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "preset": [ + ["M", 2], + ["W", 9], + ["W", 8], + ["W", 13], + ["W", 39], + ["W", 41], + ["W", 19], + ["W", 48], + ["W", 12], + ["W", 46], + ["W", 28] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [92, 101, 106], + "par": 106, + "solution": [ + ["S", 34], + ["D", 23], + ["D", 53], + ["D", 61] + ] + }, + { + "id": "d-039", + "chapter": 0, + "seed": 120, + "trait": "CHEAP_DRONES", + "generatorVersion": 1, + "preset": [ + ["M", 46], + ["D", 20], + ["D", 27], + ["D", 1], + ["D", 39], + ["D", 58], + ["S", 69], + ["D", 78], + ["D", 67], + ["D", 57], + ["W", 74], + ["W", 51], + ["W", 38], + ["W", 36], + ["W", 53], + ["W", 60], + ["W", 80], + ["D", 56] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [108, 113, 115], + "par": 115, + "solution": [ + ["S", 2], + ["D", 3], + ["D", 13], + ["D", 23] + ] + }, + { + "id": "d-040", + "chapter": 0, + "seed": 123, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1, + "preset": [ + ["M", 78], + ["W", 32], + ["W", 15], + ["W", 73], + ["W", 54], + ["W", 36], + ["W", 49], + ["W", 52], + ["W", 35], + ["W", 68], + ["W", 66], + ["W", 71] + ], + "moves": 3, + "units": null, + "goal": "energy", + "stars": [107, 116, 121], + "par": 121, + "solution": [ + ["S", 29], + ["D", 27], + ["D", 47] + ] + }, + { + "id": "d-041", + "chapter": 0, + "seed": 128, + "trait": "DOUBLE_DRONE_COST", + "generatorVersion": 1, + "preset": [ + ["M", 5], + ["W", 11], + ["W", 15], + ["W", 18], + ["W", 0], + ["W", 17], + ["W", 12] + ], + "moves": 3, + "units": null, + "goal": "energy", + "stars": [57, 62, 64], + "par": 64, + "solution": [ + ["S", 41], + ["D", 59], + ["W", 20] + ] + }, + { + "id": "d-042", + "chapter": 0, + "seed": 130, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "preset": [ + ["M", 15], + ["W", 8], + ["W", 23], + ["W", 41], + ["W", 2], + ["W", 60], + ["W", 78], + ["W", 4], + ["W", 80], + ["W", 52], + ["W", 35], + ["S", 18], + ["W", 37], + ["W", 19], + ["W", 30] + ], + "moves": 5, + "units": null, + "goal": "resources", + "stars": [5, 5, 4], + "par": 4, + "solution": [ + ["S", 38], + ["S", 54], + ["S", 67], + ["D", 68] + ] + }, + { + "id": "d-043", + "chapter": 0, + "seed": 134, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1, + "preset": [ + ["M", 73], + ["W", 38], + ["W", 58], + ["W", 68], + ["W", 66], + ["W", 65], + ["W", 63], + ["W", 45] + ], + "moves": 3, + "units": null, + "goal": "energy", + "stars": [66, 74, 78], + "par": 78, + "solution": [ + ["S", 24], + ["D", 7], + ["D", 43] + ] + }, + { + "id": "d-044", + "chapter": 0, + "seed": 136, + "trait": "SINGLE_WORKER_PER_ROOM", + "generatorVersion": 1, + "preset": [ + ["M", 76], + ["W", 48], + ["W", 70], + ["W", 65], + ["D", 52], + ["D", 46], + ["D", 54], + ["D", 50], + ["W", 68] + ], + "moves": 3, + "units": null, + "goal": "energy", + "stars": [89, 102, 109], + "par": 109, + "solution": [ + ["S", 20], + ["D", 4], + ["D", 9] + ] + }, + { + "id": "d-045", + "chapter": 0, + "seed": 138, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "preset": [ + ["M", 22], + ["W", 14], + ["W", 30], + ["W", 35], + ["W", 17], + ["W", 15], + ["W", 28], + ["W", 9] + ], + "moves": 5, + "units": null, + "goal": "resources", + "stars": [5, 5, 4], + "par": 4, + "solution": [ + ["D", 32], + ["D", 50], + ["S", 60], + ["D", 78] + ] + }, + { + "id": "d-046", + "chapter": 0, + "seed": 144, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "preset": [ + ["M", 3], + ["W", 13] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [41, 53, 59], + "par": 59, + "solution": [ + ["D", 48], + ["W", 37], + ["S", 33], + ["D", 42] + ] + }, + { + "id": "d-047", + "chapter": 0, + "seed": 145, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1, + "preset": [ + ["M", 42], + ["W", 31], + ["W", 38], + ["W", 23], + ["W", 68], + ["W", 50], + ["W", 79], + ["W", 25], + ["W", 7], + ["W", 34], + ["W", 52], + ["W", 71], + ["S", 49], + ["D", 56], + ["D", 48], + ["D", 58] + ], + "moves": 3, + "units": null, + "goal": "energy", + "stars": [41, 44, 46], + "par": 46, + "solution": [ + ["S", 10], + ["W", 11], + ["D", 27] + ] + }, + { + "id": "d-048", + "chapter": 0, + "seed": 146, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "preset": [ + ["M", 2], + ["W", 21], + ["W", 23], + ["W", 25], + ["W", 12], + ["W", 10], + ["W", 27], + ["W", 15], + ["W", 17], + ["W", 47], + ["D", 19], + ["W", 36] + ], + "moves": 3, + "units": null, + "goal": "energy", + "stars": [107, 112, 115], + "par": 115, + "solution": [ + ["S", 41], + ["D", 30], + ["W", 60] + ] + }, + { + "id": "d-049", + "chapter": 0, + "seed": 150, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1, + "preset": [ + ["M", 14], + ["W", 24], + ["W", 22], + ["W", 40], + ["W", 8], + ["W", 47], + ["W", 58], + ["W", 60], + ["W", 76], + ["W", 25], + ["W", 42], + ["W", 6], + ["W", 4], + ["W", 2] + ], + "moves": 4, + "units": null, + "goal": "resources", + "stars": [4, 4, 3], + "par": 3, + "solution": [ + ["S", 18], + ["D", 37], + ["S", 63] + ] + }, + { + "id": "d-050", + "chapter": 0, + "seed": 154, + "trait": "DOUBLE_DRONE_COST", + "generatorVersion": 1, + "preset": [ + ["M", 74], + ["W", 30], + ["W", 68], + ["W", 37], + ["W", 55], + ["S", 13], + ["W", 4], + ["W", 6], + ["W", 48], + ["W", 79], + ["S", 45], + ["W", 61], + ["W", 11], + ["W", 9], + ["W", 66] + ], + "moves": 4, + "units": ["HIVEMOTHER", "HIVEWORKER", "HIVESCOUT"], + "goal": "energy", + "stars": [93, 97, 98], + "par": 98, + "solution": [ + ["S", 26], + ["W", 7], + ["W", 25], + ["W", 33] + ] + }, + { + "id": "d-051", + "chapter": 0, + "seed": 160, + "trait": "CHEAP_DRONES", + "generatorVersion": 1, + "preset": [ + ["M", 23], + ["D", 12], + ["D", 0], + ["D", 10], + ["D", 27], + ["D", 31], + ["D", 35], + ["D", 16], + ["D", 6], + ["D", 29], + ["D", 40], + ["D", 48], + ["D", 44], + ["D", 52], + ["S", 55], + ["W", 15], + ["W", 13], + ["W", 2], + ["W", 58] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [154, 159, 161], + "par": 161, + "solution": [ + ["S", 60], + ["D", 62], + ["D", 69], + ["D", 79] + ] + }, + { + "id": "d-052", + "chapter": 0, + "seed": 162, + "trait": "CHEAP_DRONES", + "generatorVersion": 1, + "preset": [ + ["M", 41], + ["D", 77], + ["D", 21], + ["D", 13], + ["D", 5], + ["D", 30], + ["W", 19], + ["D", 36], + ["D", 63], + ["D", 60], + ["W", 33], + ["W", 52], + ["W", 31], + ["W", 49], + ["D", 74], + ["D", 66], + ["D", 58] + ], + "moves": 3, + "units": null, + "goal": "energy", + "stars": [128, 133, 136], + "par": 136, + "solution": [ + ["S", 1], + ["D", 0] + ] + }, + { + "id": "d-053", + "chapter": 0, + "seed": 163, + "trait": "DOUBLE_DRONE_COST", + "generatorVersion": 1, + "preset": [ + ["M", 45], + ["D", 37], + ["W", 39], + ["S", 70], + ["W", 41], + ["W", 24], + ["W", 57], + ["W", 52], + ["W", 79], + ["W", 77], + ["W", 59], + ["W", 53], + ["W", 35], + ["W", 71], + ["W", 72], + ["W", 74], + ["W", 56] + ], + "moves": 4, + "units": ["HIVEMOTHER", "HIVEWORKER", "HIVESCOUT"], + "goal": "energy", + "stars": [109, 113, 114], + "par": 114, + "solution": [ + ["S", 4], + ["W", 5], + ["W", 11], + ["W", 22] + ] + }, + { + "id": "d-054", + "chapter": 0, + "seed": 165, + "trait": "DOUBLE_DRONE_COST", + "generatorVersion": 1, + "preset": [ + ["M", 8], + ["W", 43], + ["W", 13], + ["W", 60], + ["W", 15], + ["S", 31], + ["W", 14], + ["W", 16], + ["W", 33], + ["S", 27], + ["W", 46], + ["W", 28], + ["W", 2], + ["W", 0], + ["S", 63], + ["W", 48], + ["W", 59], + ["W", 76], + ["W", 72], + ["W", 54], + ["W", 65] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [87, 91, 92], + "par": 92, + "solution": [ + ["S", 70], + ["W", 77], + ["W", 79], + ["W", 61] + ] + }, + { + "id": "d-055", + "chapter": 0, + "seed": 166, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1, + "preset": [ + ["M", 11], + ["W", 4], + ["W", 39], + ["W", 32], + ["W", 21], + ["W", 15], + ["D", 8], + ["W", 26], + ["W", 1] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [91, 96, 99], + "par": 99, + "solution": [ + ["S", 53], + ["D", 79], + ["W", 34], + ["W", 41] + ] + }, + { + "id": "d-056", + "chapter": 0, + "seed": 168, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "preset": [ + ["M", 6], + ["W", 12], + ["W", 14], + ["W", 31], + ["W", 43], + ["W", 52], + ["W", 70], + ["W", 32], + ["W", 25], + ["W", 8], + ["W", 2] + ], + "moves": 3, + "units": null, + "goal": "energy", + "stars": [97, 109, 115], + "par": 115, + "solution": [ + ["D", 19], + ["S", 39], + ["D", 59] + ] + }, + { + "id": "d-057", + "chapter": 0, + "seed": 172, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "preset": [ + ["M", 7], + ["W", 12], + ["W", 11], + ["W", 14], + ["W", 0], + ["W", 35], + ["W", 17], + ["W", 24], + ["W", 51], + ["D", 44] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [94, 102, 106], + "par": 106, + "solution": [ + ["S", 18], + ["D", 38], + ["S", 31], + ["D", 50] + ] + }, + { + "id": "d-058", + "chapter": 0, + "seed": 174, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1, + "preset": [ + ["M", 20], + ["W", 14], + ["W", 3], + ["W", 31], + ["W", 9], + ["W", 57], + ["W", 50], + ["W", 28], + ["W", 45], + ["W", 12], + ["W", 30], + ["W", 1], + ["S", 54], + ["D", 55], + ["D", 63] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [55, 60, 63], + "par": 63, + "solution": [ + ["S", 33], + ["D", 26], + ["W", 35], + ["W", 42] + ] + }, + { + "id": "d-059", + "chapter": 0, + "seed": 176, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "preset": [ + ["M", 4], + ["W", 16], + ["W", 49], + ["W", 32], + ["W", 12], + ["D", 14], + ["W", 30], + ["S", 45], + ["W", 54], + ["W", 73], + ["W", 47], + ["W", 28], + ["W", 9], + ["S", 48], + ["W", 57] + ], + "moves": 5, + "units": null, + "goal": "resources", + "stars": [5, 5, 4], + "par": 4, + "solution": [ + ["S", 33], + ["D", 51], + ["D", 61], + ["S", 80] + ] + }, + { + "id": "d-060", + "chapter": 0, + "seed": 180, + "trait": "CHEAP_DRONES", + "generatorVersion": 1, + "preset": [ + ["M", 57], + ["D", 40], + ["D", 32], + ["D", 47], + ["D", 64], + ["W", 21], + ["D", 74], + ["S", 36], + ["W", 18], + ["W", 45], + ["W", 29], + ["W", 67], + ["S", 79], + ["W", 78], + ["D", 70], + ["D", 62] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [84, 89, 91], + "par": 91, + "solution": [ + ["S", 0], + ["D", 1], + ["D", 9], + ["D", 12] + ] + }, + { + "id": "d-061", + "chapter": 0, + "seed": 185, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1, + "preset": [ + ["M", 68], + ["W", 24], + ["W", 49], + ["W", 42], + ["W", 63], + ["W", 45], + ["W", 38], + ["W", 74], + ["W", 76], + ["W", 55], + ["W", 57], + ["W", 60], + ["W", 62], + ["W", 43] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [123, 131, 135], + "par": 135, + "solution": [ + ["W", 78], + ["S", 23], + ["D", 12], + ["D", 14] + ] + }, + { + "id": "d-062", + "chapter": 0, + "seed": 188, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1, + "preset": [ + ["M", 30], + ["W", 23], + ["W", 42], + ["W", 25], + ["W", 40], + ["W", 43], + ["W", 62], + ["W", 2], + ["W", 20], + ["W", 4], + ["W", 22], + ["W", 49], + ["W", 38], + ["W", 36], + ["W", 54], + ["W", 68], + ["W", 75], + ["W", 19] + ], + "moves": 4, + "units": null, + "goal": "resources", + "stars": [4, 4, 3], + "par": 3, + "solution": [ + ["S", 5], + ["S", 8], + ["S", 64] + ] + }, + { + "id": "d-063", + "chapter": 0, + "seed": 195, + "trait": "CHEAP_DRONES", + "generatorVersion": 1, + "preset": [ + ["M", 53], + ["D", 48], + ["D", 25], + ["D", 58], + ["D", 22], + ["D", 33], + ["D", 61], + ["S", 12], + ["D", 31], + ["D", 39], + ["D", 41], + ["D", 19], + ["D", 38], + ["D", 45], + ["D", 69], + ["S", 16], + ["W", 3], + ["W", 43], + ["W", 60], + ["W", 1] + ], + "moves": 4, + "units": ["HIVEMOTHER", "DRONE", "HIVESCOUT"], + "goal": "energy", + "stars": [159, 164, 166], + "par": 166, + "solution": [ + ["S", 47], + ["D", 56], + ["D", 64], + ["D", 72] + ] + }, + { + "id": "d-064", + "chapter": 0, + "seed": 196, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1, + "preset": [ + ["M", 45], + ["W", 28], + ["W", 12], + ["W", 39], + ["W", 55], + ["W", 57], + ["W", 9], + ["W", 68], + ["W", 74], + ["W", 51], + ["W", 38], + ["W", 58], + ["W", 31], + ["W", 73] + ], + "moves": 3, + "units": null, + "goal": "energy", + "stars": [107, 110, 112], + "par": 112, + "solution": [ + ["S", 24], + ["D", 23], + ["W", 6] + ] + }, + { + "id": "d-065", + "chapter": 0, + "seed": 197, + "trait": "SINGLE_WORKER_PER_ROOM", + "generatorVersion": 1, + "preset": [ + ["M", 38], + ["W", 21], + ["D", 24], + ["W", 73], + ["D", 32], + ["D", 55], + ["W", 48], + ["S", 1], + ["W", 0], + ["D", 28], + ["D", 18], + ["S", 3], + ["S", 8], + ["W", 17], + ["D", 14], + ["D", 6], + ["D", 12] + ], + "moves": 3, + "units": ["HIVEMOTHER", "HIVEWORKER", "HIVESCOUT"], + "goal": "energy", + "stars": [68, 73, 75], + "par": 75, + "solution": [ + ["S", 59], + ["W", 52], + ["W", 66] + ] + }, + { + "id": "d-066", + "chapter": 0, + "seed": 198, + "trait": "CHEAP_DRONES", + "generatorVersion": 1, + "preset": [ + ["M", 67], + ["D", 48], + ["D", 52], + ["D", 62], + ["D", 54], + ["D", 79], + ["S", 44], + ["D", 74], + ["W", 75], + ["W", 57] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [97, 102, 104], + "par": 104, + "solution": [ + ["S", 23], + ["D", 11], + ["D", 14], + ["D", 22] + ] + }, + { + "id": "d-067", + "chapter": 0, + "seed": 199, + "trait": "CHEAP_DRONES", + "generatorVersion": 1, + "preset": [ + ["M", 38], + ["D", 1], + ["D", 45], + ["D", 28], + ["D", 18], + ["D", 12], + ["D", 4], + ["S", 24], + ["W", 31], + ["D", 15], + ["D", 23], + ["D", 25], + ["D", 43], + ["D", 7] + ], + "moves": 3, + "units": null, + "goal": "energy", + "stars": [68, 77, 82], + "par": 82, + "solution": [ + ["S", 59], + ["D", 50], + ["D", 79] + ] + }, + { + "id": "d-068", + "chapter": 0, + "seed": 201, + "trait": "DOUBLE_DRONE_COST", + "generatorVersion": 1, + "preset": [ + ["M", 39], + ["W", 47], + ["S", 4], + ["W", 5], + ["W", 65], + ["W", 7], + ["W", 2], + ["W", 9], + ["W", 28], + ["S", 63], + ["W", 31], + ["W", 27], + ["W", 33], + ["W", 29], + ["W", 72] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [87, 91, 92], + "par": 92, + "solution": [ + ["S", 43], + ["W", 44], + ["W", 49], + ["W", 60] + ] + }, + { + "id": "d-069", + "chapter": 0, + "seed": 210, + "trait": "CHEAP_DRONES", + "generatorVersion": 1, + "preset": [ + ["M", 66], + ["D", 50], + ["D", 40], + ["D", 47], + ["D", 56], + ["D", 72], + ["D", 22], + ["S", 9], + ["D", 14], + ["W", 29], + ["W", 76], + ["W", 69], + ["W", 58], + ["D", 18], + ["D", 28], + ["D", 0], + ["D", 2], + ["D", 10] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [87, 99, 105], + "par": 105, + "solution": [ + ["S", 15], + ["D", 6], + ["D", 16], + ["D", 26] + ] + }, + { + "id": "d-070", + "chapter": 0, + "seed": 212, + "trait": "DOUBLE_DRONE_COST", + "generatorVersion": 1, + "preset": [ + ["M", 38], + ["W", 31], + ["W", 42], + ["W", 30], + ["W", 60], + ["W", 13], + ["W", 75], + ["W", 55], + ["W", 66], + ["W", 49], + ["W", 68], + ["S", 44], + ["D", 70], + ["W", 64], + ["W", 27], + ["W", 46], + ["W", 48], + ["W", 26], + ["W", 43] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [130, 134, 135], + "par": 135, + "solution": [ + ["S", 6], + ["W", 8], + ["W", 23], + ["W", 25] + ] + }, + { + "id": "d-071", + "chapter": 0, + "seed": 216, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "preset": [ + ["M", 3], + ["W", 22], + ["W", 20], + ["W", 18], + ["W", 14], + ["S", 26], + ["W", 44], + ["W", 25], + ["W", 23], + ["W", 7] + ], + "moves": 5, + "units": null, + "goal": "resources", + "stars": [5, 5, 4], + "par": 4, + "solution": [ + ["D", 29], + ["D", 46], + ["S", 33], + ["D", 40] + ] + }, + { + "id": "d-072", + "chapter": 0, + "seed": 218, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "preset": [ + ["M", 4], + ["W", 12], + ["W", 10], + ["W", 39], + ["W", 21], + ["W", 37], + ["W", 15], + ["W", 34], + ["W", 17], + ["S", 58], + ["W", 73], + ["W", 77], + ["W", 75], + ["W", 79], + ["W", 60], + ["W", 53], + ["W", 59], + ["D", 43], + ["W", 35] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [139, 146, 150], + "par": 150, + "solution": [ + ["S", 54], + ["W", 36], + ["D", 19], + ["W", 56] + ] + }, + { + "id": "d-073", + "chapter": 0, + "seed": 220, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "preset": [ + ["M", 5], + ["W", 13], + ["W", 12], + ["W", 31], + ["W", 15], + ["W", 19], + ["W", 1], + ["W", 42], + ["D", 24], + ["W", 61], + ["S", 39], + ["W", 30], + ["W", 50], + ["W", 48], + ["W", 37], + ["W", 68], + ["W", 75], + ["W", 57], + ["W", 38], + ["W", 54], + ["W", 36], + ["W", 18] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [132, 137, 140], + "par": 140, + "solution": [ + ["D", 16], + ["D", 35] + ] + }, + { + "id": "d-074", + "chapter": 0, + "seed": 221, + "trait": "DOUBLE_DRONE_COST", + "generatorVersion": 1, + "preset": [ + ["M", 32], + ["W", 39], + ["W", 22], + ["W", 20], + ["W", 2], + ["W", 15], + ["W", 49], + ["S", 0], + ["W", 51], + ["W", 44], + ["W", 13], + ["W", 35], + ["W", 24], + ["W", 17] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [94, 101, 104], + "par": 104, + "solution": [ + ["S", 56], + ["D", 73], + ["W", 57], + ["W", 77] + ] + }, + { + "id": "d-075", + "chapter": 0, + "seed": 235, + "trait": "DOUBLE_DRONE_COST", + "generatorVersion": 1, + "preset": [ + ["M", 29], + ["D", 66], + ["W", 41], + ["W", 21], + ["W", 48], + ["W", 46], + ["S", 62], + ["W", 80], + ["W", 60], + ["W", 36], + ["W", 34], + ["W", 61], + ["W", 19], + ["W", 1] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [95, 99, 100], + "par": 100, + "solution": [ + ["S", 4], + ["W", 5], + ["W", 7], + ["W", 22] + ] + }, + { + "id": "d-076", + "chapter": 0, + "seed": 238, + "trait": "CHEAP_DRONES", + "generatorVersion": 1, + "preset": [ + ["M", 37], + ["D", 3], + ["D", 11], + ["D", 0], + ["D", 47], + ["D", 54], + ["D", 32], + ["D", 22], + ["S", 34], + ["D", 51], + ["D", 58], + ["D", 48], + ["D", 13], + ["W", 5], + ["D", 7], + ["D", 17], + ["W", 27], + ["W", 29], + ["W", 31] + ], + "moves": 3, + "units": null, + "goal": "energy", + "stars": [145, 152, 156], + "par": 156, + "solution": [ + ["S", 61], + ["D", 78], + ["D", 80] + ] + }, + { + "id": "d-077", + "chapter": 0, + "seed": 239, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "preset": [ + ["M", 10], + ["W", 0], + ["W", 18], + ["W", 20], + ["W", 3], + ["W", 4], + ["W", 5], + ["W", 22], + ["W", 29], + ["W", 25], + ["W", 8] + ], + "moves": 3, + "units": null, + "goal": "energy", + "stars": [83, 91, 95], + "par": 95, + "solution": [ + ["D", 47], + ["W", 39] + ] + }, + { + "id": "d-078", + "chapter": 0, + "seed": 241, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "preset": [ + ["M", 14], + ["W", 26], + ["W", 44], + ["W", 51], + ["W", 68], + ["W", 66], + ["W", 24], + ["W", 22], + ["W", 40], + ["W", 6], + ["W", 1], + ["W", 3], + ["W", 7], + ["W", 20], + ["W", 0], + ["W", 18] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [110, 115, 117], + "par": 117, + "solution": [ + ["S", 38], + ["D", 64], + ["D", 75], + ["W", 39] + ] + }, + { + "id": "d-079", + "chapter": 0, + "seed": 244, + "trait": "CHEAP_DRONES", + "generatorVersion": 1, + "preset": [ + ["M", 18], + ["D", 14], + ["D", 39], + ["D", 31], + ["D", 11], + ["S", 29], + ["D", 50], + ["D", 40], + ["D", 33], + ["S", 57], + ["W", 28], + ["W", 58], + ["W", 75], + ["W", 10], + ["W", 3], + ["D", 56] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [100, 105, 107], + "par": 107, + "solution": [ + ["S", 5], + ["D", 6], + ["D", 16], + ["D", 35] + ] + }, + { + "id": "d-080", + "chapter": 0, + "seed": 252, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1, + "preset": [ + ["M", 35], + ["W", 41] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [54, 65, 71], + "par": 71, + "solution": [ + ["W", 22], + ["S", 29], + ["D", 9], + ["D", 12] + ] + }, + { + "id": "d-081", + "chapter": 0, + "seed": 257, + "trait": "SINGLE_WORKER_PER_ROOM", + "generatorVersion": 1, + "preset": [ + ["M", 63], + ["W", 37], + ["W", 19], + ["D", 20], + ["W", 55], + ["D", 1] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [57, 68, 74], + "par": 74, + "solution": [ + ["S", 68], + ["D", 70], + ["S", 42], + ["D", 62] + ] + }, + { + "id": "d-082", + "chapter": 0, + "seed": 261, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "preset": [ + ["M", 10], + ["W", 25], + ["W", 0], + ["W", 32], + ["W", 43], + ["W", 29], + ["W", 18], + ["W", 2], + ["W", 36], + ["W", 23], + ["W", 4], + ["W", 6], + ["W", 47] + ], + "moves": 4, + "units": ["HIVEMOTHER", "HIVEWORKER", "HIVESCOUT"], + "goal": "energy", + "stars": [91, 96, 98], + "par": 98, + "solution": [ + ["S", 42], + ["W", 16], + ["W", 60], + ["W", 79] + ] + }, + { + "id": "d-083", + "chapter": 0, + "seed": 263, + "trait": "DOUBLE_DRONE_COST", + "generatorVersion": 1, + "preset": [ + ["M", 28], + ["D", 22], + ["W", 63], + ["W", 56], + ["S", 6], + ["W", 2], + ["W", 20], + ["W", 18], + ["W", 0], + ["W", 15], + ["W", 36], + ["W", 54], + ["W", 8], + ["W", 26] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [81, 85, 86], + "par": 86, + "solution": [ + ["S", 33], + ["W", 35], + ["W", 42], + ["W", 48] + ] + }, + { + "id": "d-084", + "chapter": 0, + "seed": 264, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1, + "preset": [ + ["M", 32], + ["D", 52], + ["W", 24], + ["W", 6], + ["W", 37], + ["W", 21], + ["W", 3], + ["W", 48], + ["W", 55], + ["W", 8], + ["W", 39], + ["W", 42], + ["W", 22], + ["W", 26] + ], + "moves": 3, + "units": null, + "goal": "energy", + "stars": [122, 125, 127], + "par": 127, + "solution": [ + ["S", 57], + ["D", 63], + ["W", 66] + ] + }, + { + "id": "d-085", + "chapter": 0, + "seed": 265, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "preset": [ + ["M", 13], + ["W", 21], + ["W", 30], + ["W", 19], + ["W", 50], + ["W", 23], + ["W", 41], + ["W", 57], + ["W", 6], + ["D", 25], + ["W", 33], + ["W", 8], + ["S", 38], + ["W", 47], + ["W", 64], + ["W", 27], + ["W", 45], + ["S", 34], + ["W", 43], + ["W", 62] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [96, 101, 104], + "par": 104, + "solution": [ + ["D", 1], + ["D", 9] + ] + }, + { + "id": "d-086", + "chapter": 0, + "seed": 270, + "trait": "CHEAP_DRONES", + "generatorVersion": 1, + "preset": [ + ["M", 31], + ["D", 3], + ["D", 12], + ["D", 23], + ["D", 39], + ["D", 56], + ["D", 76], + ["D", 8], + ["D", 16], + ["D", 36], + ["D", 47], + ["W", 55], + ["S", 27], + ["W", 41], + ["W", 35], + ["W", 17], + ["D", 18], + ["D", 20] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [123, 132, 137], + "par": 137, + "solution": [ + ["S", 51], + ["D", 44], + ["D", 50], + ["D", 71] + ] + }, + { + "id": "d-087", + "chapter": 0, + "seed": 276, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1, + "preset": [ + ["M", 52], + ["W", 6], + ["W", 42], + ["W", 23], + ["W", 40], + ["W", 44], + ["W", 62], + ["W", 80], + ["W", 8], + ["W", 35], + ["W", 60], + ["W", 79] + ], + "moves": 3, + "units": null, + "goal": "resources", + "stars": [3, 3, 2], + "par": 2, + "solution": [ + ["S", 58], + ["S", 75] + ] + }, + { + "id": "d-088", + "chapter": 0, + "seed": 277, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1, + "preset": [ + ["M", 69], + ["D", 75], + ["W", 25], + ["W", 8], + ["W", 6], + ["W", 59], + ["W", 48], + ["W", 41], + ["W", 34], + ["W", 53], + ["W", 62], + ["W", 77], + ["W", 80], + ["D", 65], + ["D", 57] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [88, 93, 95], + "par": 95, + "solution": [ + ["S", 11], + ["D", 9], + ["W", 12], + ["W", 14] + ] + }, + { + "id": "d-089", + "chapter": 0, + "seed": 279, + "trait": "SINGLE_WORKER_PER_ROOM", + "generatorVersion": 1, + "preset": [ + ["M", 47], + ["D", 31], + ["W", 42], + ["W", 64], + ["W", 73], + ["S", 15], + ["W", 8], + ["D", 44], + ["D", 4], + ["D", 1], + ["D", 55], + ["D", 66], + ["D", 76], + ["W", 20], + ["W", 13], + ["S", 12], + ["S", 18], + ["W", 9] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [106, 109, 110], + "par": 110, + "solution": [ + ["D", 11], + ["D", 21], + ["D", 27] + ] + }, + { + "id": "d-090", + "chapter": 0, + "seed": 280, + "trait": "DOUBLE_DRONE_COST", + "generatorVersion": 1, + "preset": [ + ["M", 20], + ["W", 39], + ["W", 56], + ["W", 3], + ["W", 5], + ["W", 10], + ["W", 34], + ["S", 52], + ["D", 71], + ["W", 58], + ["W", 76], + ["W", 74], + ["W", 13], + ["W", 31], + ["W", 30], + ["W", 41], + ["W", 43], + ["W", 59], + ["W", 28] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [124, 128, 129], + "par": 129, + "solution": [ + ["S", 7], + ["W", 6], + ["W", 8], + ["W", 25] + ] + }, + { + "id": "d-091", + "chapter": 0, + "seed": 285, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "preset": [ + ["M", 3], + ["W", 13], + ["W", 16], + ["W", 9], + ["W", 35], + ["W", 40], + ["W", 11], + ["W", 57], + ["W", 47], + ["W", 28], + ["S", 60], + ["W", 71], + ["W", 53], + ["W", 51], + ["W", 43], + ["S", 14], + ["W", 25], + ["W", 23] + ], + "moves": 5, + "units": null, + "goal": "resources", + "stars": [5, 5, 4], + "par": 4, + "solution": [ + ["D", 59], + ["S", 56], + ["D", 65], + ["S", 79] + ] + }, + { + "id": "d-092", + "chapter": 0, + "seed": 286, + "trait": "CHEAP_DRONES", + "generatorVersion": 1, + "preset": [ + ["M", 25], + ["D", 49], + ["D", 39], + ["W", 35], + ["W", 17], + ["W", 6], + ["D", 52], + ["D", 44], + ["D", 32], + ["D", 42], + ["S", 11] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [37, 49, 55], + "par": 55, + "solution": [ + ["D", 4], + ["D", 12], + ["S", 59], + ["D", 71] + ] + }, + { + "id": "d-093", + "chapter": 0, + "seed": 287, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1, + "preset": [ + ["M", 65], + ["W", 46], + ["W", 58], + ["W", 61], + ["W", 50], + ["W", 39], + ["W", 76], + ["W", 27], + ["W", 9], + ["W", 12], + ["W", 19], + ["W", 0], + ["W", 55] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [109, 117, 121], + "par": 121, + "solution": [ + ["W", 72], + ["S", 22], + ["D", 6], + ["D", 33] + ] + }, + { + "id": "d-094", + "chapter": 0, + "seed": 288, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1, + "preset": [ + ["M", 34], + ["W", 26], + ["W", 8], + ["W", 80], + ["W", 69], + ["W", 40], + ["W", 59], + ["W", 42], + ["W", 22], + ["W", 3], + ["W", 6], + ["W", 44], + ["W", 62] + ], + "moves": 4, + "units": null, + "goal": "resources", + "stars": [4, 4, 3], + "par": 3, + "solution": [ + ["S", 46], + ["D", 55], + ["S", 65] + ] + }, + { + "id": "d-095", + "chapter": 0, + "seed": 289, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "preset": [ + ["M", 20], + ["W", 30], + ["W", 66], + ["W", 55], + ["W", 72], + ["W", 15], + ["W", 8], + ["W", 26], + ["W", 12], + ["W", 5], + ["W", 46], + ["W", 39], + ["W", 50], + ["W", 10] + ], + "moves": 3, + "units": null, + "goal": "energy", + "stars": [89, 94, 97], + "par": 97, + "solution": [ + ["S", 58], + ["D", 69], + ["W", 33] + ] + }, + { + "id": "d-096", + "chapter": 0, + "seed": 291, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1, + "preset": [ + ["M", 37], + ["W", 30], + ["W", 48], + ["W", 32], + ["W", 47], + ["W", 45], + ["W", 64], + ["W", 29], + ["W", 27], + ["D", 18], + ["D", 0] + ], + "moves": 3, + "units": null, + "goal": "resources", + "stars": [3, 3, 2], + "par": 2, + "solution": [ + ["D", 2], + ["S", 23] + ] + }, + { + "id": "d-097", + "chapter": 0, + "seed": 292, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1, + "preset": [ + ["M", 15], + ["W", 26], + ["W", 43], + ["W", 21], + ["W", 4], + ["W", 23], + ["W", 41], + ["W", 3], + ["W", 10], + ["W", 5], + ["W", 25] + ], + "moves": 5, + "units": ["HIVEMOTHER", "HIVEWORKER", "HIVESCOUT"], + "goal": "resources", + "stars": [5, 5, 4], + "par": 4, + "solution": [ + ["S", 37], + ["S", 64], + ["W", 73], + ["S", 63] + ] + }, + { + "id": "d-098", + "chapter": 0, + "seed": 295, + "trait": "CHEAP_DRONES", + "generatorVersion": 1, + "preset": [ + ["M", 43], + ["D", 21], + ["D", 24], + ["D", 32], + ["D", 15], + ["D", 60], + ["D", 49], + ["D", 35], + ["D", 8], + ["D", 80], + ["W", 62], + ["W", 31], + ["W", 53], + ["W", 51], + ["W", 68], + ["W", 14] + ], + "moves": 3, + "units": null, + "goal": "energy", + "stars": [156, 165, 170], + "par": 170, + "solution": [ + ["S", 20], + ["D", 2], + ["D", 27] + ] + }, + { + "id": "d-099", + "chapter": 0, + "seed": 298, + "trait": "DOUBLE_DRONE_COST", + "generatorVersion": 1, + "preset": [ + ["M", 33], + ["W", 5], + ["W", 59], + ["W", 77], + ["W", 16], + ["W", 23], + ["W", 25], + ["S", 50], + ["W", 79], + ["W", 62], + ["W", 41] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [89, 94, 96], + "par": 96, + "solution": [ + ["S", 11], + ["W", 4], + ["W", 27], + ["D", 45] + ] + }, + { + "id": "d-100", + "chapter": 0, + "seed": 299, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1, + "preset": [ + ["M", 68], + ["D", 54], + ["W", 61], + ["W", 56], + ["W", 58], + ["W", 78], + ["W", 80], + ["W", 76], + ["W", 51], + ["W", 49] + ], + "moves": 3, + "units": null, + "goal": "energy", + "stars": [101, 104, 106], + "par": 106, + "solution": [ + ["S", 30], + ["D", 29], + ["W", 4] + ] + }, + { + "id": "d-101", + "chapter": 0, + "seed": 300, + "trait": "CHEAP_DRONES", + "generatorVersion": 1, + "preset": [ + ["M", 20], + ["D", 27], + ["D", 39], + ["W", 24], + ["D", 42], + ["D", 35], + ["D", 32], + ["W", 12], + ["W", 5], + ["D", 0], + ["D", 10], + ["S", 8], + ["D", 6], + ["D", 17] + ], + "moves": 3, + "units": null, + "goal": "energy", + "stars": [120, 129, 134], + "par": 134, + "solution": [ + ["D", 25], + ["S", 55], + ["D", 75] + ] + }, + { + "id": "d-102", + "chapter": 0, + "seed": 302, + "trait": "CHEAP_DRONES", + "generatorVersion": 1, + "preset": [ + ["M", 71], + ["D", 66], + ["D", 76], + ["S", 43], + ["D", 41], + ["D", 34], + ["S", 8], + ["W", 24], + ["W", 13], + ["W", 61], + ["W", 78], + ["W", 6], + ["W", 26], + ["D", 52] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [73, 78, 80], + "par": 80, + "solution": [ + ["S", 4], + ["D", 2], + ["D", 21], + ["D", 29] + ] + }, + { + "id": "d-103", + "chapter": 0, + "seed": 303, + "trait": "CHEAP_DRONES", + "generatorVersion": 1, + "preset": [ + ["M", 3], + ["D", 20], + ["D", 31], + ["D", 41], + ["D", 10], + ["D", 46], + ["W", 27], + ["W", 38], + ["W", 6], + ["W", 23] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [103, 116, 123], + "par": 123, + "solution": [ + ["S", 34], + ["D", 35], + ["S", 51], + ["D", 71] + ] + }, + { + "id": "d-104", + "chapter": 0, + "seed": 307, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "preset": [ + ["M", 14], + ["W", 59], + ["W", 76], + ["W", 79], + ["W", 3], + ["W", 18], + ["W", 40], + ["W", 1], + ["W", 42], + ["W", 25], + ["W", 22], + ["D", 4], + ["W", 6] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [115, 127, 133], + "par": 133, + "solution": [ + ["S", 66], + ["D", 72], + ["W", 46], + ["D", 63] + ] + }, + { + "id": "d-105", + "chapter": 0, + "seed": 311, + "trait": "DOUBLE_DRONE_COST", + "generatorVersion": 1, + "preset": [ + ["M", 22], + ["D", 50], + ["W", 30], + ["W", 48], + ["W", 32], + ["W", 17], + ["W", 15], + ["W", 41], + ["W", 52], + ["W", 35], + ["W", 3], + ["W", 5], + ["S", 2], + ["W", 11], + ["W", 0], + ["W", 29] + ], + "moves": 3, + "units": null, + "goal": "energy", + "stars": [115, 120, 122], + "par": 122, + "solution": [ + ["S", 54], + ["D", 74], + ["W", 36] + ] + }, + { + "id": "d-106", + "chapter": 0, + "seed": 313, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "preset": [ + ["M", 2], + ["W", 12], + ["W", 30], + ["W", 37], + ["W", 19], + ["W", 0], + ["W", 47], + ["W", 65], + ["W", 45], + ["S", 39], + ["W", 51], + ["W", 40], + ["W", 58], + ["W", 77], + ["W", 70], + ["S", 16], + ["W", 7] + ], + "moves": 4, + "units": ["HIVEMOTHER", "DRONE", "HIVESCOUT"], + "goal": "energy", + "stars": [89, 98, 103], + "par": 103, + "solution": [ + ["D", 13], + ["D", 15], + ["S", 42], + ["D", 43] + ] + }, + { + "id": "d-107", + "chapter": 0, + "seed": 315, + "trait": "CHEAP_DRONES", + "generatorVersion": 1, + "preset": [ + ["M", 21], + ["D", 4], + ["D", 11], + ["D", 1], + ["D", 9], + ["D", 7], + ["D", 35], + ["D", 15], + ["D", 40], + ["W", 32], + ["D", 14], + ["W", 29], + ["W", 18], + ["S", 37], + ["D", 36], + ["D", 63], + ["D", 28] + ], + "moves": 4, + "units": null, + "goal": "resources", + "stars": [4, 4, 3], + "par": 3, + "solution": [ + ["S", 42], + ["S", 71], + ["S", 74] + ] + }, + { + "id": "d-108", + "chapter": 0, + "seed": 317, + "trait": "DOUBLE_DRONE_COST", + "generatorVersion": 1, + "preset": [ + ["M", 38], + ["W", 32], + ["W", 49], + ["W", 48], + ["W", 59], + ["W", 66], + ["W", 3], + ["W", 5], + ["W", 21], + ["W", 1], + ["W", 18], + ["W", 45], + ["W", 30], + ["W", 19], + ["S", 51], + ["W", 60], + ["W", 79], + ["W", 77] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [106, 110, 112], + "par": 112, + "solution": [ + ["S", 16], + ["W", 7], + ["W", 34], + ["D", 62] + ] + }, + { + "id": "d-109", + "chapter": 0, + "seed": 322, + "trait": "CHEAP_DRONES", + "generatorVersion": 1, + "preset": [ + ["M", 67], + ["D", 39], + ["D", 59], + ["D", 80], + ["S", 12], + ["D", 18], + ["D", 61], + ["W", 21], + ["W", 57], + ["W", 77], + ["W", 75], + ["W", 10], + ["D", 5], + ["D", 3], + ["D", 13] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [93, 98, 100], + "par": 100, + "solution": [ + ["S", 7], + ["D", 8], + ["D", 16], + ["D", 32] + ] + }, + { + "id": "d-110", + "chapter": 0, + "seed": 323, + "trait": "CHEAP_DRONES", + "generatorVersion": 1, + "preset": [ + ["M", 39], + ["D", 64], + ["D", 22], + ["D", 2], + ["D", 46], + ["D", 27], + ["D", 11], + ["D", 19], + ["D", 0], + ["W", 76], + ["D", 49], + ["S", 69], + ["D", 70], + ["D", 80], + ["D", 53], + ["W", 31], + ["W", 56], + ["W", 72], + ["W", 54], + ["W", 74] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [156, 161, 163], + "par": 163, + "solution": [ + ["S", 5], + ["D", 6], + ["D", 14], + ["D", 17] + ] + }, + { + "id": "d-111", + "chapter": 0, + "seed": 326, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1, + "preset": [ + ["M", 17], + ["W", 22], + ["W", 43], + ["W", 50], + ["W", 6], + ["W", 4], + ["W", 7], + ["W", 24], + ["S", 68], + ["D", 79], + ["W", 77], + ["W", 59], + ["W", 52], + ["D", 67], + ["D", 65], + ["D", 57], + ["D", 69], + ["S", 30], + ["W", 36], + ["W", 19], + ["W", 41], + ["W", 23], + ["W", 39], + ["W", 54], + ["W", 47] + ], + "moves": 3, + "units": null, + "goal": "energy", + "stars": [77, 81, 83], + "par": 83, + "solution": [ + ["W", 0], + ["W", 2], + ["W", 28] + ] + }, + { + "id": "d-112", + "chapter": 0, + "seed": 328, + "trait": "CHEAP_DRONES", + "generatorVersion": 1, + "preset": [ + ["M", 35], + ["D", 15], + ["D", 25], + ["W", 41], + ["D", 23], + ["D", 60], + ["W", 52], + ["W", 62], + ["W", 42], + ["S", 40], + ["D", 1], + ["D", 9], + ["D", 39], + ["D", 49], + ["D", 29], + ["D", 21], + ["D", 5] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [141, 156, 165], + "par": 165, + "solution": [ + ["D", 2], + ["S", 65], + ["D", 64], + ["D", 74] + ] + }, + { + "id": "d-113", + "chapter": 0, + "seed": 329, + "trait": "CHEAP_DRONES", + "generatorVersion": 1, + "preset": [ + ["M", 2], + ["D", 37], + ["W", 15], + ["D", 17], + ["D", 21], + ["D", 13], + ["D", 32], + ["S", 35], + ["D", 52], + ["W", 10] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [113, 125, 131], + "par": 131, + "solution": [ + ["S", 59], + ["D", 31], + ["D", 41], + ["D", 78] + ] + }, + { + "id": "d-114", + "chapter": 0, + "seed": 331, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1, + "preset": [ + ["M", 41], + ["W", 22], + ["W", 29], + ["W", 48], + ["W", 31], + ["W", 24], + ["W", 15], + ["W", 4], + ["W", 21], + ["W", 49], + ["W", 35], + ["D", 8], + ["W", 16], + ["W", 68] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [112, 117, 119], + "par": 119, + "solution": [ + ["S", 47], + ["D", 66], + ["W", 18], + ["W", 0] + ] + }, + { + "id": "d-115", + "chapter": 0, + "seed": 332, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1, + "preset": [ + ["M", 32], + ["W", 6], + ["W", 21], + ["W", 24], + ["W", 38], + ["W", 8], + ["W", 60], + ["W", 4], + ["W", 76], + ["W", 58], + ["W", 78], + ["W", 80], + ["W", 62], + ["W", 25], + ["W", 43] + ], + "moves": 4, + "units": null, + "goal": "resources", + "stars": [4, 4, 3], + "par": 3, + "solution": [ + ["D", 49], + ["S", 11], + ["S", 63] + ] + }, + { + "id": "d-116", + "chapter": 0, + "seed": 336, + "trait": "DOUBLE_DRONE_COST", + "generatorVersion": 1, + "preset": [ + ["M", 12], + ["W", 23], + ["W", 31], + ["W", 49], + ["W", 38], + ["W", 6], + ["S", 35], + ["W", 53], + ["W", 4], + ["W", 60], + ["W", 41], + ["W", 0], + ["W", 18], + ["W", 37], + ["W", 1], + ["W", 19], + ["S", 57], + ["W", 59], + ["W", 66], + ["W", 74], + ["W", 72] + ], + "moves": 3, + "units": ["HIVEMOTHER", "HIVEWORKER", "HIVESCOUT"], + "goal": "energy", + "stars": [132, 135, 137], + "par": 137, + "solution": [ + ["W", 56], + ["W", 45] + ] + }, + { + "id": "d-117", + "chapter": 0, + "seed": 337, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "preset": [ + ["M", 2], + ["W", 8], + ["W", 12], + ["W", 15], + ["W", 19], + ["W", 39], + ["W", 50], + ["W", 13], + ["W", 28], + ["W", 45] + ], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [79, 91, 97], + "par": 97, + "solution": [ + ["S", 51], + ["D", 62], + ["D", 67], + ["W", 69] + ] + }, + { + "id": "d-118", + "chapter": 0, + "seed": 338, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "preset": [["M", 29]], + "moves": 4, + "units": null, + "goal": "energy", + "stars": [34, 45, 51], + "par": 51, + "solution": [ + ["W", 18], + ["D", 48], + ["W", 35], + ["W", 37] + ] + }, + { + "id": "d-119", + "chapter": 0, + "seed": 341, + "trait": "TANDEM_MINING", + "generatorVersion": 1, + "preset": [["M", 1]], + "moves": 5, + "units": null, + "goal": "energy", + "stars": [32, 42, 48], + "par": 48, + "solution": [ + ["W", 9], + ["D", 5], + ["W", 12], + ["D", 15], + ["W", 27] + ] + }, + { + "id": "d-120", + "chapter": 0, + "seed": 342, + "trait": "DOUBLE_SCOUT_COST", + "generatorVersion": 1, + "preset": [ + ["M", 42], + ["W", 29], + ["W", 7], + ["W", 25], + ["W", 31], + ["W", 11], + ["W", 9], + ["W", 14], + ["W", 3], + ["W", 50], + ["W", 49], + ["W", 78], + ["W", 67], + ["W", 30], + ["W", 32], + ["W", 35] + ], + "moves": 5, + "units": ["HIVEMOTHER", "DRONE", "HIVEWORKER"], + "goal": "energy", + "stars": [113, 118, 121], + "par": 121, + "solution": [ + ["D", 61], + ["D", 80] + ] + } +] diff --git a/src/engine/tsconfig.json b/src/engine/tsconfig.json new file mode 100644 index 0000000..46845d5 --- /dev/null +++ b/src/engine/tsconfig.json @@ -0,0 +1,14 @@ +// TypeScript-Konfiguration für die Engine-Kopie aus Ninelands (src/engine/VERSION). +// Angelehnt an die Einstellungen des Hauptspiels, damit die Kopie unverändert baut. +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2023", "WebWorker"], + // duel.ts nutzt Parameter-Properties; esbuild übersetzt sie, `node --experimental-strip-types` nicht + "erasableSyntaxOnly": false, + "strict": true, + "noUncheckedIndexedAccess": false, + "resolveJsonModule": true + }, + "include": ["core/**/*.ts", "campaign/*.json", "puzzles/*.json"] +} diff --git a/src/server/tsconfig.json b/src/server/tsconfig.json index a114d79..241354d 100644 --- a/src/server/tsconfig.json +++ b/src/server/tsconfig.json @@ -5,5 +5,5 @@ "types": ["node"], "customConditions": ["server"] }, - "references": [{"path": "../shared/"}] + "references": [{"path": "../shared/"}, {"path": "../engine/"}] } diff --git a/src/shared/daily.test.ts b/src/shared/daily.test.ts new file mode 100644 index 0000000..72e1eea --- /dev/null +++ b/src/shared/daily.test.ts @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict' +import {test} from 'node:test' +import {BOTS} from '../engine/core/bots.ts' +import {Game} from '../engine/core/Game.ts' +import {computeGoals} from '../engine/core/goals.ts' +import {applyMoves, encodeMoves} from '../engine/core/replay.ts' +import {dailySeed, mulberry32} from '../engine/core/rng.ts' +import {dailyBoard, dailyMap} from './daily.ts' + +test('Tageskarte ist deterministisch und je Tag verschieden', () => { + const a = dailyMap(new Date('2026-11-20T00:00:00Z')) + const b = dailyMap(new Date('2026-11-20T23:59:59Z')) + const c = dailyMap(new Date('2026-11-21T12:00:00Z')) + assert.deepEqual(a, b) + 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('Reddit-Seed unterscheidet sich von der Website-Tageskarte', () => { + const date = new Date('2026-12-01T10:00:00Z') + assert.notEqual(dailyMap(date).seed, dailySeed(date)) +}) + +test('Brett ist 9 × 9 und die Engine spielt eine Partie nach', () => { + const map = dailyMap(new Date('2026-11-20T00:00:00Z')) + const board = dailyBoard(map) + assert.equal(board.size, 9) + assert.equal(board.cells, 81) + + // Bot-Partie spielen, Züge kodieren und auf einem frischen Brett nachspielen (wie später der Server) + const game = new Game(board, {undoLimit: 0}) + const rng = mulberry32(1) + while (!game.isGameOver()) { + const m = BOTS.greedy(game, rng) + game.place(m.unit, m.cell) + } + const replay = new Game(dailyBoard(map), {undoLimit: 0}) + assert.ok(applyMoves(replay, encodeMoves(game.moves))) + assert.equal(replay.score, game.score) + + const goals = computeGoals(board) + assert.ok(goals.gold >= 5) + assert.ok(goals.lands >= 1 && goals.lands <= 9) +}) diff --git a/src/shared/daily.ts b/src/shared/daily.ts new file mode 100644 index 0000000..94b5a58 --- /dev/null +++ b/src/shared/daily.ts @@ -0,0 +1,41 @@ +// Tageskarte für Reddit: eigener Seed (anderes Präfix als die Website), sonst wie die „Karte des Tages“ im Hauptspiel. + +import type {Board} from '../engine/core/Board.ts' +import {generateBoard, rolledTrait} from '../engine/core/MapGenerator.ts' +import {dailyKey, fnv1a, mapCodeFromSeed} from '../engine/core/rng.ts' +import type {WorldTrait} from '../engine/core/WorldRules.ts' + +/** Präfix des Tages-Seeds. Nie ändern – sonst ändern sich alle Tageskarten (und Bestenlisten) rückwirkend. */ +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 { + /** UTC-Tag „JJJJ-MM-TT“ */ + day: string + /** laufende Nummer ab FIRST_DAY (1 = erster Tag) */ + number: number + seed: number + mapId: string + trait: WorldTrait +} + +export function dailyMap(date: Date): DailyMap { + const day = dailyKey(date) + const seed = fnv1a(`${DAILY_SEED_PREFIX}${day}`) + const number = + Math.round((Date.parse(day) - Date.parse(FIRST_DAY)) / 86_400_000) + 1 + return { + day, + number, + seed, + mapId: mapCodeFromSeed(seed), + trait: rolledTrait(seed), + } +} + +/** Das 9 × 9-Brett der Tageskarte */ +export function dailyBoard(map: DailyMap): Board { + return generateBoard(map.seed, {trait: map.trait}) +} diff --git a/src/shared/tsconfig.json b/src/shared/tsconfig.json index 0f15936..99c5b18 100644 --- a/src/shared/tsconfig.json +++ b/src/shared/tsconfig.json @@ -1,5 +1,6 @@ // TypeScript config for isomorphic code. { "extends": "../../tsconfig.base.json", - "compilerOptions": {"lib": ["WebWorker", "ES2023"]} + "compilerOptions": {"lib": ["WebWorker", "ES2023"]}, + "references": [{"path": "../engine/"}] } diff --git a/src/test/tsconfig.json b/src/test/tsconfig.json index 1286a3f..3c07bab 100644 --- a/src/test/tsconfig.json +++ b/src/test/tsconfig.json @@ -10,6 +10,7 @@ "references": [ {"path": "../client/"}, {"path": "../server/"}, - {"path": "../shared/"} + {"path": "../shared/"}, + {"path": "../engine/"} ] } diff --git a/tsconfig.json b/tsconfig.json index 4ea12f4..ca8768a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,7 @@ "files": [], "references": [ {"path": "./src/client/"}, + {"path": "./src/engine/"}, {"path": "./src/server/"}, {"path": "./src/shared/"}, {"path": "./src/test/"}