feat(sasch40): improve Act III, add state persistence and fix portraits

- Fix character portrait crops and version assets to portrait_v2, ensuring correct identities (resolving Nat/Verena mismatch)
- Update Schankraum scene image and configure Nginx cache-control to prevent stale asset caching
- Make secret beer tap completely invisible in tavern until clicked
- Reposition room hotspots in Act III to eliminate overlapping click targets
- Add exit button [✕] to Bavarian Gstanzl duel minigame
- Implement localStorage persistence in gameStore to maintain progress across page reloads
- Enable clean game state and storage reset when clicking "Nochmal spielen"
- Expand automated playtest script to cover Nat dialogue, duel exit, reload persistence, and game restart
This commit is contained in:
Moritz Graf
2026-09-24 11:03:22 +02:00
parent 7a7038e2c2
commit e45dbc2e19
34 changed files with 363 additions and 78 deletions
+1
View File
@@ -16,6 +16,7 @@ This repository is divided into distinct domains, each with its own `AGENTS.md`
* **Purpose**: Deploying applications and services to the cluster.
* **Method**: GitOps-style directory structure (Namespace = Folder).
* **Constraints**: Enforces `ingress-nginx`, `cert-manager` (Let's Encrypt), and `git-crypt` for secrets.
* **OpenClaw**: For OpenClaw AI agent deployment and troubleshooting, see [k8s/openclaw/AGENTS.md](./k8s/openclaw/AGENTS.md) and [k8s/openclaw/TROUBLESHOOTING.md](./k8s/openclaw/TROUBLESHOOTING.md).
* **Read**: [k8s/AGENTS.md](./k8s/AGENTS.md)
### 3. Terraform (`terraform/`)
+5 -2
View File
@@ -82,9 +82,12 @@ OpenClaw connects to three foundational Git repositories:
---
## 🚨 Troubleshooting & Live Verification
## 🚨 Troubleshooting & Incident Playbook
If the agent is offline or failing:
> [!IMPORTANT]
> **Mandatory Bug & Incident Documentation**: Whenever a bug, outage, schema error, or operational regression is fixed in OpenClaw, **always document the incident in [TROUBLESHOOTING.md](./TROUBLESHOOTING.md)** with symptom, root cause, resolution, and how to avoid recurrence.
For known issues, resolved bugs, and specific failure playbooks, consult [TROUBLESHOOTING.md](./TROUBLESHOOTING.md).
### 1. Verify Deployment Health
Ensure that the `OpenClawInstance` is successfully reconciled:
+90
View File
@@ -0,0 +1,90 @@
# OpenClaw Troubleshooting & Incident Playbook
This document captures fixed issues, operational quirks, and incident root-cause analyses for the **OpenClaw** deployment on Kubernetes.
> [!IMPORTANT]
> **Rule for Agents & Operators**: Whenever you fix a bug, diagnose an operational failure, or resolve a configuration pitfall in OpenClaw, **document it here immediately**. This prevents recurring regressions and serves as the single source of truth for troubleshooting.
---
## 📋 Catalog of Incidents & Fixed Issues
| Date | Category | Summary / Symptom | Status | Key Resolution |
| :--- | :--- | :--- | :---: | :--- |
| **2026-09-20** | **Timeout & Execution** | Telegram `#IT Guy` (Topic 119) and `#Recherche` (Topic 117) request timed out before response generated (`timeoutSeconds` exceeded) | `RESOLVED` | Raised global `agents.defaults.timeoutSeconds` from 600 to 1800 (30 min). |
| **2026-09-20** | **Config Schema Validation** | Pod crash with `CrashLoopBackOff`: `agents.entries.<id>: Unrecognized key: "timeoutSeconds"` | `RESOLVED` | `timeoutSeconds` is strictly global under `agents.defaults.timeoutSeconds`; do not place it under `agents.entries.*`. |
| **2026-09-24** | **Database & Startup Latency** | Gateway crash/restart during initialization: `slow OpenClaw agent database open` leading to Startup/Liveness probe failures | `RESOLVED` | Probe budget vs WAL checkpointing on large SQLite files (~1.5GB across subagents). |
---
## 🔍 Detailed Incident Records
### Issue 1: Telegram Agent Request Timeout (`agents.defaults.timeoutSeconds`)
- **Date**: 2026-09-20
- **Severity**: High (User-facing timeout in Telegram topics `#IT Guy` and `#Recherche`)
- **Symptom**:
Telegram replies with error message:
```text
Request timed out before a response was generated. Please try again, or increase agents.defaults.timeoutSeconds in your config.
```
- **Root Cause**:
The IT Guy agent (`itguy`) performs deep, sequential DevOps investigations (multiple `exec` / `kubectl` calls, file writes, and PR operations) using `deepseek/deepseek-v4.1-flash`. Similarly, `#Recherche` runs deep research chains. The global default was set to `timeoutSeconds: 600` (10 minutes), causing active, non-stuck runs to be killed mid-flight.
- **Resolution**:
In [`k8s/openclaw/openclaw-instance.secret.yaml`](file:///Users/moritz/src/infrapuzzle/k8s/openclaw/openclaw-instance.secret.yaml), configure:
```yaml
spec:
config:
raw:
agents:
defaults:
timeoutSeconds: 1800 # 30 minutes
```
- **How to Avoid Recurrence**:
Always verify timeout requirements against the longest-running task types (multi-step K8s audits and deep research). Never assume 10 minutes is sufficient for multi-turn tool loops.
---
### Issue 2: Schema Crash on Per-Agent Timeout Override
- **Date**: 2026-09-20
- **Severity**: Critical (Deployment blocked with `CrashLoopBackOff`)
- **Symptom**:
Gateway container fails during startup:
```text
Gateway failed to start: Invalid config at /home/openclaw/.openclaw/openclaw.json:
openclaw.json:169 — agents.entries.itguy: Unrecognized key: "timeoutSeconds"
```
- **Root Cause**:
OpenClaw’s JSON schema strict validator does not permit `timeoutSeconds` inside individual agent entries under `agents.entries.<name>`.
- **Resolution**:
Remove `timeoutSeconds` from `agents.entries.*`. Configure it strictly at the root of `agents.defaults.timeoutSeconds`.
- **How to Avoid Recurrence**:
Before adding new keys to agent configurations in `openclaw-instance.secret.yaml`, inspect the upstream OpenClaw schema or test locally to ensure the key is recognized under that exact path.
---
### Issue 3: Startup Probe Failures due to Slow SQLite Database Initialization
- **Date**: 2026-09-24
- **Severity**: High (Intermittent container kills on gateway restart)
- **Symptom**:
Container `openclaw` fails startup or liveness probes, causing kubelet to restart the container:
```text
Liveness probe failed: Get "http://...:18790/healthz": context deadline exceeded
Startup probe failed: context deadline exceeded / connection reset by peer
```
Logs display repeated warnings:
```text
[state/agent-db] slow OpenClaw agent database open
```
- **Root Cause**:
OpenClaw maintains separate SQLite databases for each specialist subagent (`agents/*/agent/openclaw-agent.sqlite`). Over time, databases and uncommitted WAL logs accumulate significant size:
- `agents/main/agent/openclaw-agent.sqlite`: >220 MB
- `agents/fitness/`, `agents/itguy/`, `agents/hebamme/`, `agents/recherche/`: >100 MB each + multi-megabyte WAL files.
On startup, SQLite WAL recovery and integrity passes across 10+ databases on single-node hostpath storage can take several minutes. If this exceeds the `startupProbe` limit (`failureThreshold * periodSeconds`), Kubernetes kills the container before it finishes opening databases.
- **Resolution**:
1. Ensure `probes.startup.failureThreshold` in `openclaw-instance.secret.yaml` allows adequate startup headroom (e.g. at least 120 attempts with 5s period = 600s).
2. Periodically ensure SQLite VACUUM / WAL checkpoints run during maintenance windows or let the database open without interrupting it.
- **How to Avoid Recurrence**:
Monitor SQLite database sizes under `/home/openclaw/.openclaw/agents/*/agent/` and avoid overly aggressive liveness/startup probe kill thresholds during gateway initialization.
+11 -4
View File
@@ -12,9 +12,16 @@ server {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 30d;
add_header Cache-Control "public, no-transform";
# Static unhashed images, audio, and media - never cache to allow instant updates
location ~* \.(png|jpg|jpeg|gif|ico|svg|webp|mp3|wav|ogg)$ {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
expires -1;
}
# Vite bundled hashed JS/CSS assets
location ~* \.(js|css)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 416 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 359 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 972 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 972 KiB

+55 -10
View File
@@ -171,6 +171,18 @@ async function runPlaytest() {
if (actionBtn) await actionBtn.click();
await new Promise(r => setTimeout(r, 400));
}
// Click NAT (Josef)
const natBtn = await page.$('.hotspot-nat');
if (natBtn) {
await natBtn.click();
await new Promise(r => setTimeout(r, 700));
console.log('📸 09b: Capturing NAT (Josef) Dialog...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '09b_tavern_nat_dialog.png') });
const actionBtn = await page.$('.dialog-actions .action-btn');
if (actionBtn) await actionBtn.click();
await new Promise(r => setTimeout(r, 400));
}
}
// 7. TAVERN: HINTERLAND (Mane hiking)
@@ -196,7 +208,7 @@ async function runPlaytest() {
}
// 8. TAVERN: SECRET HINTERZIMMER (VIP Rave via Secret Tap)
console.log('🗝️ Returning to Schankraum to trigger Secret Tap...');
console.log('🗝️ Returning to Schankraum to check Secret Tap (initially invisible)...');
const schankraumTab = await page.$('.room-tab:nth-child(1)');
if (schankraumTab) {
await schankraumTab.click();
@@ -242,23 +254,46 @@ async function runPlaytest() {
await new Promise(r => setTimeout(r, 400));
}
console.log('⚔️ Testing Gstanzl-Duel...');
console.log('⚔️ Testing Gstanzl-Duel and [✕] Exit Button...');
const duelBtn = await page.$('.duel-trigger');
if (duelBtn) {
await duelBtn.click();
await new Promise(r => setTimeout(r, 700));
console.log('📸 15: Capturing Gstanzl Duel...');
console.log('📸 15: Capturing Gstanzl Duel with [✕] button...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '15_tavern_gstanzl_duel.png') });
const firstAnswer = await page.$('.choice-btn');
if (firstAnswer) {
await firstAnswer.click();
await new Promise(r => setTimeout(r, 500));
// Test the [✕] close button
const closeDuelBtn = await page.$('.duel-close-btn');
if (closeDuelBtn) {
console.log('Testing duel exit via [✕] button...');
await closeDuelBtn.click();
await new Promise(r => setTimeout(r, 600));
const duelClosed = await page.$('.duel-box') === null;
console.log('Duel modal closed successfully by [✕]:', duelClosed);
}
// Close duel if won or modal
const actionBtn = await page.$('.duel-box .action-btn');
if (actionBtn) await actionBtn.click();
}
// Test reload persistence in Tavern Act
console.log('💾 Testing game state reload persistence...');
const stateBeforeReload = await page.evaluate(() => {
const app = document.querySelector('#app')?.__vue_app__;
const pinia = app?.config?.globalProperties?.$pinia;
const s = pinia?._s.get('game');
return s ? { currentAct: s.currentAct, kronkorken: s.kronkorken, beers: s.beers } : null;
});
console.log('State before reload:', stateBeforeReload);
await page.reload({ waitUntil: 'networkidle0' });
await new Promise(r => setTimeout(r, 1000));
const stateAfterReload = await page.evaluate(() => {
const app = document.querySelector('#app')?.__vue_app__;
const pinia = app?.config?.globalProperties?.$pinia;
const s = pinia?._s.get('game');
return s ? { currentAct: s.currentAct, kronkorken: s.kronkorken, beers: s.beers } : null;
});
console.log('State after reload:', stateAfterReload);
// 10. PARTY EXPRESS (Act IV)
console.log('🚂 Advancing to Party Express (Act IV)...');
await page.evaluate(() => {
@@ -310,6 +345,16 @@ async function runPlaytest() {
console.log('📸 18: Capturing Certificate Modal...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '18_certificate_screen.png') });
// Test NOCHMAL SPIELEN button
console.log('🔄 Testing NOCHMAL SPIELEN button...');
const restartBtn = await page.$('.restart-btn');
if (restartBtn) {
await restartBtn.click();
await new Promise(r => setTimeout(r, 1000));
console.log('📸 19: Capturing post-restart Intro screen...');
await page.screenshot({ path: path.join(SCREENSHOTS_DIR, '19_post_restart_intro.png') });
}
// Verify all images loaded properly
const brokenImages = await page.evaluate(() => {
const imgs = Array.from(document.querySelectorAll('img'));
@@ -31,6 +31,13 @@ class SoundEngine {
return this.isMuted
}
public setMuted(val: boolean) {
this.isMuted = val
if (this.isMuted) {
this.stopBGM()
}
}
// SFX: Kronkorken Collect (bright double ding)
public playCoin() {
if (this.isMuted) return
@@ -34,7 +34,7 @@ function printOrSave() {
<div class="king-portrait-wrapper">
<img
src="/assets/characters/sascha/portrait.jpg"
src="/assets/characters/sascha/portrait_v2.jpg"
alt="König Sascha I."
class="king-portrait"
/>
@@ -23,7 +23,7 @@ function handleStart() {
<div class="king-hero">
<div class="king-frame">
<img
src="/assets/characters/sascha/portrait.jpg"
src="/assets/characters/sascha/portrait_v2.jpg"
alt="König Sascha I."
class="king-hero-img"
/>
@@ -26,11 +26,20 @@ const duelStep = ref(0)
const duelWon = ref(false)
const zulumanMirrorActive = ref(false)
const secretTapDiscovered = ref(store.unlockedEasterEggs.includes('hinterzimmer'))
function switchRoom(room: TavernRoom) {
sound.playDialogueBlip()
currentRoom.value = room
}
function triggerSecretTap() {
sound.playFanfare()
secretTapDiscovered.value = true
store.unlockEasterEgg('hinterzimmer')
currentRoom.value = 'hinterzimmer'
}
function enterSecretHinterzimmer() {
sound.playFanfare()
store.unlockEasterEgg('hinterzimmer')
@@ -229,7 +238,7 @@ function proceedToExpress() {
<!-- ================= 1. GASTSTUBE (SCHANKRAUM) ================= -->
<template v-if="currentRoom === 'schankraum'">
<img src="/assets/scenes/tavern.jpg" alt="Gaststube Zum wummernden Ritter" class="tavern-bg" />
<img src="/assets/scenes/schankraum_v2.jpg?v=2" alt="Gaststube Zum wummernden Ritter" class="tavern-bg" />
<!-- Verena on the wooden gallery -->
<button class="hotspot hotspot-verena" @click="openCharacterDialog('verena')">
@@ -267,10 +276,17 @@ function proceedToExpress() {
<span class="hotspot-badge glow-door">Zum Marktplatz ▶</span>
</button>
<!-- SECRET EASTER EGG TRIGGER: Hidden Beer Tap to Hinterzimmer -->
<button class="hotspot secret-tap-hotspot" @click="enterSecretHinterzimmer" title="Ein sonderbarer goldener Zapfhahn...">
<span class="secret-tap-icon pulse">🗝️</span>
<span class="secret-badge">Geheimer Zapfhahn</span>
<!-- SECRET EASTER EGG TRIGGER: Hidden Beer Tap to Hinterzimmer (Completely invisible until clicked) -->
<button
class="hotspot secret-tap-hotspot"
:class="{ 'tap-invisible': !secretTapDiscovered && !store.unlockedEasterEggs.includes('hinterzimmer') }"
@click="triggerSecretTap"
:title="secretTapDiscovered || store.unlockedEasterEggs.includes('hinterzimmer') ? 'Ein sonderbarer Zapfhahn...' : undefined"
>
<template v-if="secretTapDiscovered || store.unlockedEasterEggs.includes('hinterzimmer')">
<span class="secret-tap-icon pulse">🗝️</span>
<span class="secret-badge">Geheimer Zapfhahn</span>
</template>
</button>
</template>
@@ -465,8 +481,11 @@ function proceedToExpress() {
<div v-if="duelActive" class="dialog-overlay">
<div class="dialog-box duel-box">
<div class="duel-header">
<h3>⚔️ BAYERISCHES BELEIDIGUNGS-GSTANZL</h3>
<span class="duel-score">Runde {{ duelStep + 1 }} von 3</span>
<div class="duel-title-group">
<h3>⚔️ BAYERISCHES BELEIDIGUNGS-GSTANZL</h3>
<span class="duel-score">Runde {{ duelStep + 1 }} von 3</span>
</div>
<button class="duel-close-btn" @click="duelActive = false" aria-label="Minispiel verlassen">✕</button>
</div>
<div v-if="!duelWon">
@@ -578,14 +597,30 @@ function proceedToExpress() {
/* Secret Tap Easter Egg */
.secret-tap-hotspot {
position: absolute;
top: 73%;
left: 88%;
top: 72%;
left: 82%;
background: rgba(234, 179, 8, 0.2);
border: 1.5px dashed #ffd700;
border-radius: 12px;
padding: 3px 6px;
cursor: pointer;
z-index: 12;
transition: all 0.2s;
}
.secret-tap-hotspot.tap-invisible,
.secret-tap-hotspot.tap-invisible:hover,
.secret-tap-hotspot.tap-invisible:active,
.secret-tap-hotspot.tap-invisible:focus {
background: transparent !important;
border: none !important;
box-shadow: none !important;
outline: none !important;
transform: translate(-50%, -50%) !important;
width: 50px;
height: 50px;
padding: 0 !important;
cursor: pointer;
}
.secret-tap-icon {
@@ -599,18 +634,18 @@ function proceedToExpress() {
}
/* 1. Schankraum Hotspots */
.hotspot-verena { top: 16%; left: 78%; }
.hotspot-armend { top: 62%; left: 24%; }
.hotspot-lutz { top: 38%; left: 20%; }
.hotspot-amy { top: 52%; left: 80%; }
.hotspot-mirror { top: 32%; left: 63%; }
.hotspot-door-marktplatz { top: 56%; left: 90%; }
.hotspot-verena { top: 17%; left: 28%; }
.hotspot-mirror { top: 30%; left: 68%; }
.hotspot-amy { top: 45%; left: 32%; }
.hotspot-lutz { top: 54%; left: 56%; }
.hotspot-armend { top: 62%; left: 18%; }
.hotspot-door-marktplatz { top: 50%; left: 84%; }
/* 2. Marktplatz Hotspots */
.mario-container {
position: absolute;
top: 52%;
left: 34%;
top: 44%;
left: 30%;
z-index: 10;
}
@@ -664,12 +699,12 @@ function proceedToExpress() {
100% { opacity: 1; transform: scale(1.3); }
}
.hotspot-nat { top: 62%; left: 74%; }
.hotspot-door-back-gaststube { top: 72%; left: 16%; }
.hotspot-door-hinterland { top: 36%; left: 84%; }
.hotspot-nat { top: 62%; left: 70%; }
.hotspot-door-back-gaststube { top: 72%; left: 20%; }
.hotspot-door-hinterland { top: 32%; left: 76%; }
/* 3. Hinterland Hotspots */
.hotspot-mane { top: 50%; left: 42%; }
.hotspot-mane { top: 44%; left: 36%; }
.hiking-anim {
animation: hikeBob 1.2s infinite ease-in-out;
}
@@ -684,8 +719,8 @@ function proceedToExpress() {
transform: rotate(20deg);
}
.hotspot-hansi { top: 66%; left: 78%; }
.hotspot-door-back-marktplatz { top: 74%; left: 20%; }
.hotspot-hansi { top: 60%; left: 74%; }
.hotspot-door-back-marktplatz { top: 72%; left: 22%; }
/* 4. Hinterzimmer Hotspots & Strobe */
.strobe-overlay {
@@ -704,24 +739,10 @@ function proceedToExpress() {
100% { background: rgba(168, 85, 247, 0.25); }
}
.hotspot-pue { top: 46%; left: 48%; }
.rave-pulse {
animation: raveGlow 1s infinite alternate;
}
@keyframes raveGlow {
0% { box-shadow: 0 0 4px #eab308; }
100% { box-shadow: 0 0 16px #ec4899; }
}
.crystal-laser {
font-size: 13px;
animation: pulseAnim 0.8s infinite;
}
.hotspot-flo { top: 40%; left: 22%; }
.hotspot-viktor { top: 62%; left: 78%; }
.hotspot-door-back-vip { top: 78%; left: 50%; }
.hotspot-flo { top: 38%; left: 24%; }
.hotspot-viktor { top: 52%; left: 62%; }
.hotspot-pue { top: 64%; left: 82%; }
.hotspot-door-back-vip { top: 74%; left: 45%; }
.pulse {
animation: pulseAnim 1.5s infinite;
@@ -958,11 +979,18 @@ function proceedToExpress() {
/* Gstanzl */
.duel-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
border-bottom: 1px solid #78350f;
margin-bottom: 10px;
padding-bottom: 6px;
}
.duel-title-group {
flex: 1;
}
.duel-header h3 {
font-family: 'Press Start 2P', monospace;
font-size: 8px;
@@ -975,6 +1003,31 @@ function proceedToExpress() {
color: #94a3b8;
}
.duel-close-btn {
background: #7f1d1d;
color: #fef08a;
border: 1px solid #ef4444;
border-radius: 50%;
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: bold;
cursor: pointer;
margin-left: 8px;
flex-shrink: 0;
transition: all 0.2s ease;
line-height: 1;
}
.duel-close-btn:hover {
background: #dc2626;
transform: scale(1.1);
box-shadow: 0 0 8px rgba(239, 68, 68, 0.6);
}
.insult-bubble {
background: #451a03;
border-left: 3px solid #ef4444;
@@ -13,7 +13,7 @@ export const CHARACTERS: Record<string, CharacterConfig> = {
id: 'sascha',
name: 'König Sascha I.',
title: 'Der 40-jährige Imperator',
portrait: '/assets/characters/sascha/portrait.jpg',
portrait: '/assets/characters/sascha/portrait_v2.jpg',
description: 'Herrscher über Haumdaucher, Träger der heiligen Sonnenbrille und Beschützer des 135-BPM-Basses.',
famousQuote: 'O zapft is im Reiche! Wo san meine Kronkorken?!',
themeColor: '#d4af37'
@@ -22,7 +22,7 @@ export const CHARACTERS: Record<string, CharacterConfig> = {
id: 'mop',
name: 'Mop (Moritz)',
title: 'Der Welten-Erschaffer & Matrix-Architekt',
portrait: '/assets/characters/mop/portrait.jpg',
portrait: '/assets/characters/mop/portrait_v2.jpg',
description: 'Sitzt auf einer Wolke über dem Schloss und baut aus Pixeln und Hopfen diese Welt.',
famousQuote: 'Just adding a few more polygons here... wait, pixels. Pixels.',
themeColor: '#38bdf8'
@@ -31,7 +31,7 @@ export const CHARACTERS: Record<string, CharacterConfig> = {
id: 'nat',
name: 'NAT (Josef)',
title: 'Hof-Chronist & Barde des Wildschweins',
portrait: '/assets/characters/nat/portrait.jpg',
portrait: '/assets/characters/nat/portrait_v2.jpg',
description: 'Kennt alle 3 Staffeln Bridgerton in- und auswendig, trägt florale Bomberjacken und erzählt epische Geschichten.',
famousQuote: 'Also pass auf Sascha... ich damals mit meinen Girls ab...',
themeColor: '#22c55e'
@@ -40,7 +40,7 @@ export const CHARACTERS: Record<string, CharacterConfig> = {
id: 'verena',
name: 'Königin Verena',
title: 'Herrin des Hofes',
portrait: '/assets/characters/verena/portrait.jpg',
portrait: '/assets/characters/verena/portrait_v2.jpg',
description: 'Liebt Sascha über alles auf der Welt, hat aber ein unumstößliches königliches Edikt erlassen.',
famousQuote: 'Ich hab dich lieb mein Schatz, aber ich putze nach der Party fei ned!',
themeColor: '#ec4899'
@@ -49,7 +49,7 @@ export const CHARACTERS: Record<string, CharacterConfig> = {
id: 'hansi',
name: 'Hansi (Johann)',
title: 'Ritter der floralen Garde',
portrait: '/assets/characters/hansi/portrait.jpg',
portrait: '/assets/characters/hansi/portrait_v2.jpg',
description: 'Perfekt gestylter Schnurrbart und florale Plattenrüstung.',
famousQuote: 'Habe die Ehre! Der Bass im Rittersaal ist auf Punkt.',
themeColor: '#6366f1'
@@ -58,7 +58,7 @@ export const CHARACTERS: Record<string, CharacterConfig> = {
id: 'lutz',
name: 'Lutz',
title: 'Der ZTUL-Hofschornsteinfeger',
portrait: '/assets/characters/lutz/portrait.jpg',
portrait: '/assets/characters/lutz/portrait_v2.jpg',
description: 'Ein Hüne von Gestalt mit Kappe, runder Brille und dem legendären ZTUL-Trikot.',
famousQuote: 'Glück auf, Euer Durchlaucht! Schornstein ist frei für den Party-Nebel.',
themeColor: '#64748b'
@@ -67,7 +67,7 @@ export const CHARACTERS: Record<string, CharacterConfig> = {
id: 'armend',
name: 'Armend',
title: 'Meister-Schmied der Kronkorken-Manufaktur',
portrait: '/assets/characters/armend/portrait.jpg',
portrait: '/assets/characters/armend/portrait_v2.jpg',
description: 'Prachtvoller Vollbart, formt aus 40 Kronkorken den goldenen Sound-Verstärker.',
famousQuote: 'Schlag zu, solange das Blech warm ist!',
themeColor: '#f97316'
@@ -76,7 +76,7 @@ export const CHARACTERS: Record<string, CharacterConfig> = {
id: 'amy',
name: 'Amy (Amelie)',
title: 'Die Hof-Künstlerin',
portrait: '/assets/characters/amy/portrait.jpg',
portrait: '/assets/characters/amy/portrait_v2.jpg',
description: 'Kreatives Genie in Latzhose mit Dutt und feinen Kräuter-Zigarren.',
famousQuote: 'Rave the Kingdom! Die Farben brauchen mehr Bass!',
themeColor: '#a855f7'
@@ -85,7 +85,7 @@ export const CHARACTERS: Record<string, CharacterConfig> = {
id: 'pue',
name: 'Pü (Jakob)',
title: 'Lichtmeister & Kristall-Rave-Magier',
portrait: '/assets/characters/pue/portrait.jpg',
portrait: '/assets/characters/pue/portrait_v2.jpg',
description: 'Lässt im geheimen Hinterzimmer die Disco-Kristalle tanzen und erhellt die Entourage mit bunten Lasern.',
famousQuote: 'Bass auf Anschlag, Kristalle auf Stroboskop! Willkommen im geheimen Hinterzimmer!',
themeColor: '#eab308'
@@ -94,7 +94,7 @@ export const CHARACTERS: Record<string, CharacterConfig> = {
id: 'mario',
name: 'Mario',
title: 'Hof-Gaukler & Kettensägen-Virtuose',
portrait: '/assets/characters/mario/portrait.jpg',
portrait: '/assets/characters/mario/portrait_v2.jpg',
description: 'Jongliert furchtlos auf dem Marktplatz mit kreischenden Kettensägen und Hopfenkrügen.',
famousQuote: 'Pass auf die Zinken auf! Kettensägen-Akrobatik für König Sascha!',
themeColor: '#14b8a6'
@@ -103,7 +103,7 @@ export const CHARACTERS: Record<string, CharacterConfig> = {
id: 'mane',
name: 'Mane (Ramona)',
title: 'Königliche Alpen-Wanderin',
portrait: '/assets/characters/mane/portrait.jpg',
portrait: '/assets/characters/mane/portrait_v2.jpg',
description: 'Zieht mit Wanderstock, Sonnenbrille und Flechtzopf durchs alpine Hinterland von Haumdaucher.',
famousQuote: 'Schee ists hier! Noch 3 Kilometer über den Grat bis zum Gipfel-Bier!',
themeColor: '#84cc16'
@@ -112,7 +112,7 @@ export const CHARACTERS: Record<string, CharacterConfig> = {
id: 'flo_maier',
name: 'Flo Maier',
title: 'Hof-DJ & Sound-Ingenieur',
portrait: '/assets/characters/flo_maier/portrait.jpg',
portrait: '/assets/characters/flo_maier/portrait_v2.jpg',
description: 'Mastermind an den Turntables und Behüter der heiligen 135-BPM-Frequenz.',
famousQuote: 'Dieser Track drückt mehr als die königliche Kutsche!',
themeColor: '#0284c7'
@@ -121,7 +121,7 @@ export const CHARACTERS: Record<string, CharacterConfig> = {
id: 'viktor',
name: 'Viktor',
title: 'Nachtwächter & Party-Ritter',
portrait: '/assets/characters/viktor/portrait.jpg',
portrait: '/assets/characters/viktor/portrait_v2.jpg',
description: 'Wacht im samtenen Vampir-Umhang über das Nachtleben und sorgt für eiskaltes Helles.',
famousQuote: 'Die Nacht ist jung und der Gerstensaft fließt in Strömen!',
themeColor: '#b91c1c'
@@ -130,7 +130,7 @@ export const CHARACTERS: Record<string, CharacterConfig> = {
id: 'djs',
name: 'Die Hof-DJs',
title: 'Minnesänger der 135 BPM',
portrait: '/assets/characters/flo_maier/portrait.jpg',
portrait: '/assets/characters/flo_maier/portrait_v2.jpg',
description: 'Stehen an der königlichen Kanzel am doppelten Eichenholz-Plattenteller.',
famousQuote: 'Drop it like it\'s 1426! Mehr Bass auf die Burgzinnen!',
themeColor: '#06b6d4'
@@ -139,7 +139,7 @@ export const CHARACTERS: Record<string, CharacterConfig> = {
id: 'gefolge',
name: 'Das Haumdaucher Gefolge',
title: 'Die feiernde Reichs-Crowd',
portrait: '/assets/characters/viktor/portrait.jpg',
portrait: '/assets/characters/viktor/portrait_v2.jpg',
description: 'Viktor der Vampir-Ritter, DJ Flo Maier, Hansi, Fabian, Albin, der bärtige Guatemalteke, Markus & Nathalie.',
famousQuote: 'Ein Hoch auf König Sascha! Hoch soll er leben!',
themeColor: '#e11d48'
+9 -1
View File
@@ -4,5 +4,13 @@ import './style.css'
import App from './App.vue'
const app = createApp(App)
app.use(createPinia())
const pinia = createPinia()
app.use(pinia)
import { useGameStore, saveStateToStorage } from './stores/gameStore'
const store = useGameStore()
store.$subscribe((_mutation, state) => {
saveStateToStorage(state)
})
app.mount('#app')
@@ -17,8 +17,12 @@ export interface GameState {
soundMuted: boolean
}
export const useGameStore = defineStore('game', {
state: (): GameState => ({
export const STORAGE_KEY = 'sasch40_game_save_v1'
const VALID_ACTS: GameAct[] = ['intro', 'prologue', 'platformer', 'tavern', 'party_express', 'finale']
function getInitialState(): GameState {
const defaults: GameState = {
currentAct: 'intro',
kronkorken: 0,
beers: 0,
@@ -30,7 +34,66 @@ export const useGameStore = defineStore('game', {
startTime: Date.now(),
elapsedSeconds: 0,
soundMuted: false,
}),
}
if (typeof window === 'undefined' || !window.localStorage) {
return defaults
}
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return defaults
const parsed = JSON.parse(raw)
if (parsed && typeof parsed === 'object') {
const state: GameState = {
currentAct: VALID_ACTS.includes(parsed.currentAct) ? parsed.currentAct : 'intro',
kronkorken: typeof parsed.kronkorken === 'number' && !isNaN(parsed.kronkorken) ? parsed.kronkorken : 0,
beers: typeof parsed.beers === 'number' && !isNaN(parsed.beers) ? parsed.beers : 0,
guestsServed: typeof parsed.guestsServed === 'number' && !isNaN(parsed.guestsServed) ? parsed.guestsServed : 0,
gstanzlWon: Boolean(parsed.gstanzlWon),
partyMood: typeof parsed.partyMood === 'number' && !isNaN(parsed.partyMood) ? parsed.partyMood : 20,
inventory: Array.isArray(parsed.inventory) ? parsed.inventory : defaults.inventory,
unlockedEasterEggs: Array.isArray(parsed.unlockedEasterEggs) ? parsed.unlockedEasterEggs : [],
startTime: typeof parsed.startTime === 'number' ? parsed.startTime : defaults.startTime,
elapsedSeconds: typeof parsed.elapsedSeconds === 'number' ? parsed.elapsedSeconds : 0,
soundMuted: Boolean(parsed.soundMuted)
}
if (state.soundMuted) {
sound.setMuted(true)
}
return state
}
} catch (e) {
console.warn('Failed to parse saved game state:', e)
}
return defaults
}
export function saveStateToStorage(state: GameState) {
if (typeof window === 'undefined' || !window.localStorage) return
try {
const dataToSave = {
currentAct: state.currentAct,
kronkorken: state.kronkorken,
beers: state.beers,
guestsServed: state.guestsServed,
gstanzlWon: state.gstanzlWon,
partyMood: state.partyMood,
inventory: state.inventory,
unlockedEasterEggs: state.unlockedEasterEggs,
startTime: state.startTime,
elapsedSeconds: state.elapsedSeconds,
soundMuted: state.soundMuted
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(dataToSave))
} catch (e) {
console.warn('Failed to save game state to localStorage:', e)
}
}
export const useGameStore = defineStore('game', {
state: (): GameState => getInitialState(),
getters: {
isArcadeMode(state): boolean {
@@ -135,6 +198,13 @@ export const useGameStore = defineStore('game', {
},
resetGame() {
if (typeof window !== 'undefined' && window.localStorage) {
try {
localStorage.removeItem(STORAGE_KEY)
} catch (e) {
console.warn('Failed to clear saved state', e)
}
}
this.currentAct = 'intro'
this.kronkorken = 0
this.beers = 0
@@ -145,6 +215,7 @@ export const useGameStore = defineStore('game', {
this.unlockedEasterEggs = []
this.startTime = Date.now()
this.elapsedSeconds = 0
saveStateToStorage(this.$state)
}
}
})