Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
9688842
feat: make game an offline capable PWA
DaFum Apr 30, 2026
2de69c8
feat: make game an offline capable PWA
DaFum Apr 30, 2026
74cb2ca
feat: make game an offline capable PWA
DaFum May 1, 2026
6b3c0f5
feat: complete offline PWA conversion with fullscreen support
DaFum May 2, 2026
129c179
feat: localize reload prompt\n\n- Add translations for offline fallba…
DaFum May 3, 2026
9811651
feat: make game an offline capable PWA\n\n- Install \`vite-plugin-pwa…
DaFum May 3, 2026
ec6f4eb
fix: remove unportable absolute virtual-pwa.js alias\n\n- Updated \`v…
DaFum May 3, 2026
845cdb8
fix: address PR #1445 review findings
claude May 3, 2026
20a22c4
fix: resolve CI failures from base-branch bugs
claude May 3, 2026
b6eead7
fix: update pnpm-lock.yaml to match pinned dep specifiers
claude May 3, 2026
857f112
fix: add missing imageGen mock exports in performance tests
claude May 3, 2026
e8b6384
fix: resolve pre-existing Node.js test failures from base branch
claude May 3, 2026
af83cc8
Merge branch 'main' into claude/review-pr-1445-85XYg
DaFum May 3, 2026
1dc977e
fix: resolve comprehensive technical debt and PR review comments
DaFum May 3, 2026
625ca73
Merge pull request #1497 from DaFum/fix/pr-review-comments-1108332788…
DaFum May 3, 2026
f497dc2
fix: address CodeRabbit review nitpicks
claude May 3, 2026
463405e
fix(tests): convert GameState.tsx mock to URL-based specifier
claude May 3, 2026
817298f
fix(tests): address CodeRabbit outside-diff-range review comments
claude May 3, 2026
ea40b74
fix(tests): eventEngine namedExports→exports, __proto__ coverage; kab…
claude May 3, 2026
3714bc0
fix(tests): remove last remaining per-test import in kabelsalatUtils
claude May 3, 2026
eb2b73f
fix(tests): advance timers past scheduleGameEnd delay before assertin…
claude May 3, 2026
bdf78b3
fix(tests): remove remaining per-test import boilerplate in kabelsala…
claude May 3, 2026
89300b5
fix: apply Gemini review suggestions for ReloadPrompt and useMainMenu
claude May 3, 2026
15175b8
fix(tests): fix Node.js test failures from stageRenderUtils refactor
claude May 3, 2026
bcc51a6
fix(tests): update ReloadPrompt test expectations for namespaced i18n…
claude May 3, 2026
88e9a48
chore: fix technical debt, update test configurations and fix bugs
DaFum May 3, 2026
c2f0a59
Delete test_script.sh
DaFum May 3, 2026
c2cc3a5
Delete temp_tourbus.txt
DaFum May 3, 2026
65e9a96
Delete check_files.sh
DaFum May 3, 2026
c0523c1
Delete check_components.sh
DaFum May 3, 2026
52f9e89
Delete debug_path.js
DaFum May 3, 2026
2f9bf2b
Delete get_tourbus_imagegen.sh
DaFum May 3, 2026
5f67f9a
Delete fix_imageGen2.sh
DaFum May 3, 2026
a6cb8d1
Delete fix_imageGen.sh
DaFum May 3, 2026
ab08eaf
Delete fix_image_gen.sh
DaFum May 3, 2026
d9f5702
Delete fix_eventEngine.sh
DaFum May 3, 2026
eeedbc1
Merge pull request #1499 from DaFum/fix-bug-fixes-and-refactors-12451…
DaFum May 3, 2026
429ecc5
fix: add missing useNetworkStatus import and prevent image onerror in…
claude May 3, 2026
e2c1696
fix: move enterFullscreen after save-exists guard in handleStartTour
claude May 3, 2026
f5dfea1
fix: address CodeRabbit review comments for test robustness and cache…
claude May 3, 2026
8736c84
feat: add MIDI lane support and comprehensive test coverage for missi…
claude May 3, 2026
8f06c7b
fix: revert exports→namedExports in kabelsalatUtils test (Node v22 in…
claude May 3, 2026
d2d34b1
fix: address Copilot review issues in stage managers and useGigInput …
claude May 3, 2026
f6bc359
Potential fix for pull request finding
DaFum May 3, 2026
8353c43
fix: proper PWA icons, distinct offline fallback for minigame sprites
claude May 3, 2026
34647b2
fix: convert null to undefined for midiDrumKit in playDrumNote call
claude May 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ jobs:
shell: bash
run: |
rc=0
pnpm run test:ui 2>&1 | tee test-output-vitest-ui.txt || rc=$?
pnpm run test:vitest:ui 2>&1 | tee test-output-vitest-ui.txt || rc=$?
echo "exit_code=$rc" >> "$GITHUB_OUTPUT"

- name: Upload test log artifact (vitest-ui)
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,9 @@
"typescript-eslint": "^8.59.1",
"vite": "8.0.10",
"vite-plugin-compression": "0.5.1",
"vite-plugin-pwa": "1.2.0",
"vitest": "^4.0.18",
"workbox-window": "7.4.0",
"yaml": "2.8.3"
},
"engines": {
Expand Down
3,163 changes: 3,043 additions & 120 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

Binary file added public/apple-touch-icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 10 additions & 0 deletions public/images/generated-offline-fallback.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions public/locales/de/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -964,6 +964,12 @@
"unlocked": "FREIGESCHALTET: {{unlock}}!",
"visual_interface": "VISUELLE SCHNITTSTELLE",
"volume.set": "Lautstärke auf {{pct}}% setzen",
"offline": {
"offlineReady": "App bereit, offline zu arbeiten",
"needRefresh": "Neuer Inhalt verfügbar, klicke auf Neuladen zum Aktualisieren.",
"reload": "Neuladen",
"close": "Schließen"
},
"travel": {
"rivalEncounter": "Deine rivalisierende Band, {{rivalName}}, ist in der Stadt!"
},
Expand Down
6 changes: 6 additions & 0 deletions public/locales/en/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -964,6 +964,12 @@
"unlocked": "UNLOCKED: {{unlock}}!",
"visual_interface": "VISUAL INTERFACE",
"volume.set": "Set volume to {{pct}}%",
"offline": {
"offlineReady": "App ready to work offline",
"needRefresh": "New content available, click on reload button to update.",
"reload": "Reload",
"close": "Close"
},
"travel": {
"rivalEncounter": "Your rival band, {{rivalName}}, is in town!"
},
Expand Down
Binary file added public/pwa-192x192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/pwa-512x512-maskable.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/pwa-512x512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { ToastOverlay } from './ui/ToastOverlay'
import { DebugLogViewer } from './ui/DebugLogViewer'
import { TutorialManager } from './components/TutorialManager'
import { ChatterOverlay } from './components/ChatterOverlay'
import ReloadPrompt from './components/ReloadPrompt'
import { GameStateProvider, useGameState } from './context/GameState'
import { ErrorBoundary } from './ui/CrashHandler'
import { Analytics } from '@vercel/analytics/react'
Expand Down Expand Up @@ -104,6 +105,7 @@ function GameContent() {
{!SCENES_WITHOUT_HUD.has(currentScene) && <HUD />}

<ToastOverlay />
<ReloadPrompt />

{/* ChatterOverlay receives read-only state slice */}
<ChatterOverlay gameState={chatterState} />
Expand Down
7 changes: 7 additions & 0 deletions src/components/ReloadPrompt.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.ReloadPrompt-container {
position: fixed;
bottom: 20px;
right: 20px;
z-index: var(--z-toast);
pointer-events: auto;
}
61 changes: 61 additions & 0 deletions src/components/ReloadPrompt.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { useRegisterSW } from 'virtual:pwa-register/react'
import './ReloadPrompt.css'

export default function ReloadPrompt() {
const { t } = useTranslation()
const {
offlineReady: [offlineReady, setOfflineReady],
needRefresh: [needRefresh, setNeedRefresh],
updateServiceWorker
} = useRegisterSW({
onRegisterError(error) {
console.warn('SW registration error', error)
}
})

const close = () => {
setOfflineReady(false)
setNeedRefresh(false)
}

if (!offlineReady && !needRefresh) {
return null
}

return (
<div className='ReloadPrompt-container'>
<div
role='status'
className='ReloadPrompt-toast bg-void-black border-2 border-toxic-green text-toxic-green p-4 font-mono'
>
<div className='ReloadPrompt-message mb-2'>
{offlineReady ? (
<span>{t('ui:offline.offlineReady')}</span>
) : (
<span>{t('ui:offline.needRefresh')}</span>
)}
</div>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<div className='flex gap-4'>
{needRefresh && (
<button
type='button'
className='ReloadPrompt-toast-button border border-toxic-green px-3 py-1 hover:bg-toxic-green hover:text-void-black transition-colors focus-visible:ring-toxic-green focus-visible:ring-offset-void-black'
onClick={() => updateServiceWorker(true)}
>
{t('ui:offline.reload')}
</button>
)}
<button
type='button'
className='ReloadPrompt-toast-button border border-toxic-green px-3 py-1 hover:bg-toxic-green hover:text-void-black transition-colors focus-visible:ring-toxic-green focus-visible:ring-offset-void-black'
onClick={close}
>
{t('ui:offline.close')}
</button>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
</div>
</div>
</div>
)
}
127 changes: 93 additions & 34 deletions src/components/overworld/OverworldMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@ import { MapConnection } from '../MapConnection'
import { MapNode } from '../MapNode'
import { TravelingVan } from './TravelingVan'
import { calculateEffectiveTicketPrice } from '../../utils/economyEngine'
import { getGenImageUrl, IMG_PROMPTS } from '../../utils/imageGen'
import { useNetworkStatus } from '../../hooks/useNetworkStatus'
import {
getGenImageUrl,
IMG_PROMPTS,
isImageGenerationAvailable,
getGeneratedImageFallbackUrl
} from '../../utils/imageGen'
import type {
MapNode as GameMapNode,
GameMap,
Expand Down Expand Up @@ -52,40 +58,93 @@ export const OverworldMap = React.memo(
activeStoryFlags,
rivalBand
}: OverworldMapProps) => {
const isOnlineNetwork = useNetworkStatus()

// Memoized URL generators
const mapBgUrl = useMemo(
() => getGenImageUrl(IMG_PROMPTS.OVERWORLD_MAP),
[]
)
const vanUrl = useMemo(() => getGenImageUrl(IMG_PROMPTS.ICON_VAN), [])
const rivalVanUrl = useMemo(
() => getGenImageUrl(IMG_PROMPTS.ICON_RIVAL_VAN),
[]
)
const pinFestivalUrl = useMemo(
() => getGenImageUrl(IMG_PROMPTS.ICON_PIN_FESTIVAL),
[]
)
const pinHomeUrl = useMemo(
() => getGenImageUrl(IMG_PROMPTS.ICON_PIN_HOME),
[]
)
const pinClubUrl = useMemo(
() => getGenImageUrl(IMG_PROMPTS.ICON_PIN_CLUB),
[]
)
const pinRestUrl = useMemo(
() => getGenImageUrl(IMG_PROMPTS.ICON_PIN_REST),
[]
)
const pinSpecialUrl = useMemo(
() => getGenImageUrl(IMG_PROMPTS.ICON_PIN_SPECIAL),
[]
)
const pinFinaleUrl = useMemo(
() => getGenImageUrl(IMG_PROMPTS.ICON_PIN_FINALE),
[]
)
const urls = useMemo(() => {
const isOnline = isImageGenerationAvailable() && isOnlineNetwork
const createOfflineSvgUrl = (svgMarkup: string) =>
`data:image/svg+xml;utf8,${encodeURIComponent(svgMarkup)}`
const createOfflinePinUrl = (label: string, symbol: string) =>
createOfflineSvgUrl(`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="${label}">
<circle cx="32" cy="24" r="16" fill="white" stroke="black" stroke-width="3"/>
<path d="M32 58 21 34h22L32 58Z" fill="white" stroke="black" stroke-width="3" stroke-linejoin="round"/>
<text x="32" y="29" text-anchor="middle" font-family="Arial, sans-serif" font-size="16" font-weight="700" fill="black">${symbol}</text>
</svg>
`)
const createOfflineVanUrl = (label: string, text: string) =>
createOfflineSvgUrl(`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="${label}">
<rect x="10" y="20" width="34" height="20" rx="4" fill="white" stroke="black" stroke-width="3"/>
<path d="M44 26h10l4 8v6H44Z" fill="white" stroke="black" stroke-width="3" stroke-linejoin="round"/>
<circle cx="22" cy="44" r="5" fill="white" stroke="black" stroke-width="3"/>
<circle cx="48" cy="44" r="5" fill="white" stroke="black" stroke-width="3"/>
<text x="31" y="34" text-anchor="middle" font-family="Arial, sans-serif" font-size="10" font-weight="700" fill="black">${text}</text>
</svg>
`)
const offlineAssets = {
mapBgUrl: createOfflineSvgUrl(`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 450" role="img" aria-label="Offline overworld map">
<rect width="800" height="450" fill="white"/>
<path d="M40 360C140 320 220 330 320 290S520 210 620 230s100 40 140 20" fill="none" stroke="black" stroke-width="10" stroke-linecap="round"/>
<path d="M90 110c40 10 70 40 120 30s90-50 150-30 100 70 170 60 110-60 170-50" fill="none" stroke="black" stroke-width="6" stroke-dasharray="18 12" stroke-linecap="round"/>
<text x="400" y="60" text-anchor="middle" font-family="Arial, sans-serif" font-size="32" font-weight="700" fill="black">OFFLINE MAP</text>
<text x="400" y="410" text-anchor="middle" font-family="Arial, sans-serif" font-size="20" fill="black">Routes and markers remain distinct while offline</text>
</svg>
`),
vanUrl: createOfflineVanUrl('Player van', 'YOU'),
rivalVanUrl: createOfflineVanUrl('Rival van', 'RIVAL'),
pinFestivalUrl: createOfflinePinUrl('Festival node', 'F'),
pinHomeUrl: createOfflinePinUrl('Home node', 'H'),
pinClubUrl: createOfflinePinUrl('Club node', 'C'),
pinRestUrl: createOfflinePinUrl('Rest node', 'R'),
pinSpecialUrl: createOfflinePinUrl('Special node', 'S'),
pinFinaleUrl: createOfflinePinUrl('Finale node', '!')
}
const fallback = getGeneratedImageFallbackUrl()
return {
mapBgUrl: isOnline
? getGenImageUrl(IMG_PROMPTS.OVERWORLD_MAP)
: offlineAssets.mapBgUrl || fallback,
vanUrl: isOnline
? getGenImageUrl(IMG_PROMPTS.ICON_VAN)
: offlineAssets.vanUrl || fallback,
rivalVanUrl: isOnline
? getGenImageUrl(IMG_PROMPTS.ICON_RIVAL_VAN)
: offlineAssets.rivalVanUrl || fallback,
pinFestivalUrl: isOnline
? getGenImageUrl(IMG_PROMPTS.ICON_PIN_FESTIVAL)
: offlineAssets.pinFestivalUrl || fallback,
pinHomeUrl: isOnline
? getGenImageUrl(IMG_PROMPTS.ICON_PIN_HOME)
: offlineAssets.pinHomeUrl || fallback,
pinClubUrl: isOnline
? getGenImageUrl(IMG_PROMPTS.ICON_PIN_CLUB)
: offlineAssets.pinClubUrl || fallback,
pinRestUrl: isOnline
? getGenImageUrl(IMG_PROMPTS.ICON_PIN_REST)
: offlineAssets.pinRestUrl || fallback,
pinSpecialUrl: isOnline
? getGenImageUrl(IMG_PROMPTS.ICON_PIN_SPECIAL)
: offlineAssets.pinSpecialUrl || fallback,
pinFinaleUrl: isOnline
? getGenImageUrl(IMG_PROMPTS.ICON_PIN_FINALE)
: offlineAssets.pinFinaleUrl || fallback
}
}, [isOnlineNetwork])

const {
mapBgUrl,
vanUrl,
rivalVanUrl,
pinFestivalUrl,
pinHomeUrl,
pinClubUrl,
pinRestUrl,
pinSpecialUrl,
pinFinaleUrl
} = urls

// Memoized connection rendering
const renderedConnections = useMemo(() => {
Expand Down
9 changes: 7 additions & 2 deletions src/components/postGig/CompletePhase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import PropTypes from 'prop-types'
import { motion } from 'framer-motion'
import { useTranslation } from 'react-i18next'
import { ActionButton } from '../../ui/shared'
import { getGenImageUrl, IMG_PROMPTS } from '../../utils/imageGen'
import {
getGenImageUrl,
IMG_PROMPTS,
isImageGenerationAvailable,
getGeneratedImageFallbackUrl
} from '../../utils/imageGen'
import { SideEffectsSummary } from './SideEffectsSummary'
import type { CompletePhaseProps } from '../../types/components'

Expand Down Expand Up @@ -34,7 +39,7 @@ export const CompletePhase = ({
<div
className='absolute inset-0 opacity-20 bg-cover bg-center mix-blend-screen pointer-events-none z-0'
style={{
backgroundImage: `url("${getGenImageUrl(getOutcomeImagePrompt())}")`
backgroundImage: `url("${isImageGenerationAvailable() ? getGenImageUrl(getOutcomeImagePrompt()) : getGeneratedImageFallbackUrl()}")`
}}
/>
<motion.div
Expand Down
13 changes: 11 additions & 2 deletions src/components/postGig/DealCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,12 @@ import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { ActionButton } from '../../ui/shared'
import { BRAND_ALIGNMENTS } from '../../context/initialState'
import { IMG_PROMPTS, getGenImageUrl } from '../../utils/imageGen'
import {
IMG_PROMPTS,
getGenImageUrl,
isImageGenerationAvailable,
getGeneratedImageFallbackUrl
} from '../../utils/imageGen'
import type {
DealImageProps,
DealInfoProps,
Expand Down Expand Up @@ -137,7 +142,11 @@ const getAlignmentColor = (alignment?: string) => {
const DealImage = memo(({ alignment, name }: DealImageProps) => (
<div className='shrink-0 w-24 h-24 border border-current opacity-80 overflow-hidden'>
<img
src={getGenImageUrl(getAlignmentImagePrompt(alignment))}
src={
isImageGenerationAvailable()
? getGenImageUrl(getAlignmentImagePrompt(alignment))
: getGeneratedImageFallbackUrl()
}
alt={name}
className='w-full h-full object-cover object-center grayscale hover:grayscale-0 transition-all duration-300'
loading='lazy'
Expand Down
9 changes: 7 additions & 2 deletions src/components/postGig/SocialOptionButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import PropTypes from 'prop-types'
import { motion } from 'framer-motion'
import { ActionButton } from '../../ui/shared'
import { SideEffectsPreview } from './SideEffectsPreview'
import { getGenImageUrl, IMG_PROMPTS } from '../../utils/imageGen'
import {
getGenImageUrl,
IMG_PROMPTS,
isImageGenerationAvailable,
getGeneratedImageFallbackUrl
} from '../../utils/imageGen'
import type { SocialOptionButtonProps } from '../../types/components'

const CATEGORY_PROMPTS = {
Expand Down Expand Up @@ -45,7 +50,7 @@ export const SocialOptionButton = memo(function SocialOptionButton({
<div
className='absolute inset-0 opacity-80 group-hover:opacity-20 transition-opacity bg-cover bg-center pointer-events-none'
style={{
backgroundImage: `url("${getGenImageUrl(getImagePromptForCategory(opt.category, opt.badges))}")`
backgroundImage: `url("${isImageGenerationAvailable() ? getGenImageUrl(getImagePromptForCategory(opt.category, opt.badges)) : getGeneratedImageFallbackUrl()}")`
}}
/>

Expand Down
13 changes: 11 additions & 2 deletions src/components/postGig/ZealotryGauge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import PropTypes from 'prop-types'
import { ZEALOTRY_PROMO_THRESHOLD } from '../../utils/economyEngine'
import { getGenImageUrl, IMG_PROMPTS } from '../../utils/imageGen'
import {
getGenImageUrl,
IMG_PROMPTS,
isImageGenerationAvailable,
getGeneratedImageFallbackUrl
} from '../../utils/imageGen'

type ZealotryGaugeProps = { zealotryLevel?: number }

Expand All @@ -16,7 +21,11 @@ export const ZealotryGauge = memo(
<div className='flex flex-row items-center gap-4 mb-4 p-3 bg-blood-red/10 border border-blood-red/30 rounded relative overflow-hidden'>
<div className='w-12 h-12 shrink-0 border border-blood-red/50 rounded overflow-hidden'>
<img
src={getGenImageUrl(IMG_PROMPTS.ZEALOTRY_CULT)}
src={
isImageGenerationAvailable()
? getGenImageUrl(IMG_PROMPTS.ZEALOTRY_CULT)
: getGeneratedImageFallbackUrl()
}
alt={t('ui:postGig.socialPhase.altZealotryCult', {
defaultValue: 'Zealotry Cult'
})}
Expand Down
Loading
Loading