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
@@ -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/`)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 304 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 336 KiB |
|
After Width: | Height: | Size: 345 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 416 KiB |
|
After Width: | Height: | Size: 359 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 360 KiB |
|
After Width: | Height: | Size: 972 KiB |
|
After Width: | Height: | Size: 972 KiB |
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||