diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ce812f6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,49 @@ +name: CI + +on: + push: + pull_request: + workflow_dispatch: + +# A newer push to the same branch makes the running check obsolete. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + name: Typecheck, test, build + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - uses: actions/checkout@v4 + + # Pinned to the version netlify.toml deploys with, so CI and production + # never disagree about what runs. + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Typecheck + run: npm run typecheck + + - name: Test + run: npm run test + + - name: Build + run: npm run build + + - name: Check bundle budget + run: npm run check:budget + + - name: Upload build output + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist + retention-days: 7 diff --git a/.gitignore b/.gitignore index a547bf3..a10fa51 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ dist-ssr *.njsproj *.sln *.sw? + +# Local tooling cache +.atl/ diff --git a/App.tsx b/App.tsx index e10d590..746f7be 100644 --- a/App.tsx +++ b/App.tsx @@ -4,8 +4,7 @@ import { GameMode, GameState, SystemType, SystemState, GameEventOption } from '. import { SCENARIOS, TUTORIAL_STEPS, PERMANENT_UPGRADES, WIN_CONDITIONS } from './constants'; import { useGameLogic } from './hooks/useGameLogic'; import { useClientAI } from './hooks/useClientAI'; -import { useSoundSynth, DEFAULT_USER_AUDIO_MIX, normalizeUserAudioMix } from './hooks/useSoundSynth'; -import type { AudioSpatialMode, UserAudioMix } from './hooks/useSoundSynth'; +import { useSoundSynth } from './hooks/useSoundSynth'; import { Button } from './components/Button'; import { ProgressBar } from './components/ProgressBar'; import { EventCard } from './components/EventCard'; @@ -33,8 +32,9 @@ import { AchievementPanel } from './components/AchievementPanel'; import { UpgradeShop } from './components/UpgradeShop'; import { GameSettingsPanel } from './components/GameSettingsPanel'; import { computeMobileHudInsets } from './utils/mobileHudLayout'; +import { useUserSettings, VISUAL_QUALITY_LABEL } from './hooks/useUserSettings'; +import { useViewportLayout } from './hooks/useViewportLayout'; import { getVisualQualityProfile } from './utils/visualPerformance'; -import type { VisualQualityMode } from './utils/visualPerformance'; import { getCinematicTransitionStyle, getThreatLevel, getThreatRailProfile } from './utils/cinematicFx'; import { getEventImpactStyle } from './utils/impactFx'; import type { EventImpactStyle } from './utils/impactFx'; @@ -45,25 +45,6 @@ import { getMenuToolbarClasses, getMobileOverlayVisibility, sortEventsByUrgency import { getHudCinematicClasses } from './utils/uiCinematics'; import { Activity, DollarSign, Trophy, AlertOctagon, Users, ZapOff, Frown, Pause, RotateCcw, AlertTriangle, Settings, Home } from 'lucide-react'; -const VISUAL_QUALITY_STORAGE_KEY = 'event_chaos_visual_quality_mode'; -const USER_SETTINGS_STORAGE_KEY = 'event_chaos_user_settings_v1'; -const VISUAL_QUALITY_SEQUENCE: VisualQualityMode[] = ['AUTO', 'PERFORMANCE', 'CINEMATIC']; -const VISUAL_QUALITY_LABEL: Record = { - AUTO: 'AUTO', - PERFORMANCE: 'PERF', - CINEMATIC: 'CINE' -}; -const AUDIO_SPATIAL_SEQUENCE: AudioSpatialMode[] = ['BALANCED', 'CINEMATIC', 'FOCUS']; -const DEFAULT_AUDIO_SPATIAL_MODE: AudioSpatialMode = 'BALANCED'; - -interface StoredUserSettings { - visualQualityMode?: VisualQualityMode; - reducedMotion?: boolean; - highContrastUi?: boolean; - audioSpatialMode?: AudioSpatialMode; - audioMix?: Partial; -} - interface ActiveCinematicTransition { id: number; label: string; @@ -88,19 +69,6 @@ interface ActiveCameraPunch { y: number; } -const loadStoredUserSettings = (): StoredUserSettings => { - if (typeof window === 'undefined') return {}; - try { - const raw = window.localStorage.getItem(USER_SETTINGS_STORAGE_KEY); - if (!raw) return {}; - const parsed = JSON.parse(raw) as StoredUserSettings; - if (!parsed || typeof parsed !== 'object') return {}; - return parsed; - } catch { - return {}; - } -}; - const App: React.FC = () => { const { gameState, @@ -170,48 +138,26 @@ const App: React.FC = () => { const [showAchievements, setShowAchievements] = useState(false); const [showUpgrades, setShowUpgrades] = useState(false); const [showSettings, setShowSettings] = useState(false); - const [visualQualityMode, setVisualQualityMode] = useState(() => { - const storedSettings = loadStoredUserSettings(); - if (storedSettings.visualQualityMode) { - return storedSettings.visualQualityMode; - } - if (typeof window === 'undefined') return 'AUTO'; - const savedMode = window.localStorage.getItem(VISUAL_QUALITY_STORAGE_KEY); - if (savedMode === 'AUTO' || savedMode === 'PERFORMANCE' || savedMode === 'CINEMATIC') { - return savedMode; - } - return 'AUTO'; - }); - const [reducedMotion, setReducedMotion] = useState(() => { - const storedSettings = loadStoredUserSettings(); - return Boolean(storedSettings.reducedMotion); - }); - const [highContrastUi, setHighContrastUi] = useState(() => { - const storedSettings = loadStoredUserSettings(); - return Boolean(storedSettings.highContrastUi); - }); - const [audioSpatialMode, setAudioSpatialMode] = useState(() => { - const storedSettings = loadStoredUserSettings(); - const mode = storedSettings.audioSpatialMode; - if (mode === 'BALANCED' || mode === 'CINEMATIC' || mode === 'FOCUS') return mode; - return DEFAULT_AUDIO_SPATIAL_MODE; - }); - const [audioMix, setAudioMix] = useState(() => { - const storedSettings = loadStoredUserSettings(); - return normalizeUserAudioMix(storedSettings.audioMix || DEFAULT_USER_AUDIO_MIX); - }); + const { + visualQualityMode, + setVisualQualityMode, + reducedMotion, + setReducedMotion, + highContrastUi, + setHighContrastUi, + audioSpatialMode, + setAudioSpatialMode, + audioMix, + cycleVisualQuality: selectNextVisualQuality, + cycleAudioSpatial: selectNextAudioSpatial, + handleAudioMixChange, + resetSettings: restoreDefaultSettings + } = useUserSettings(); + const { isMobileLayout, isCompactViewport } = useViewportLayout(); const [mobileHudInsets, setMobileHudInsets] = useState(() => { const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : undefined; return computeMobileHudInsets({ headerHeight: 76, faderHeight: 176, viewportHeight }); }); - const [isMobileLayout, setIsMobileLayout] = useState(() => { - if (typeof window === 'undefined') return false; - return window.matchMedia('(max-width: 1023px)').matches; - }); - const [isCompactViewport, setIsCompactViewport] = useState(() => { - if (typeof window === 'undefined') return false; - return window.innerWidth < 1380 || window.innerHeight < 900; - }); const [activeTransition, setActiveTransition] = useState(null); const [activeImpactFx, setActiveImpactFx] = useState(null); const [activeFreezePulse, setActiveFreezePulse] = useState(null); @@ -226,46 +172,6 @@ const App: React.FC = () => { const freezeTimeoutRef = useRef(null); const punchTimeoutRef = useRef(null); - useEffect(() => { - if (typeof window === 'undefined') return; - const mediaQuery = window.matchMedia('(max-width: 1023px)'); - const onChange = (event: MediaQueryListEvent) => { - setIsMobileLayout(event.matches); - }; - - setIsMobileLayout(mediaQuery.matches); - mediaQuery.addEventListener('change', onChange); - return () => mediaQuery.removeEventListener('change', onChange); - }, []); - - useEffect(() => { - if (typeof window === 'undefined') return; - - const updateCompactViewport = () => { - setIsCompactViewport(window.innerWidth < 1380 || window.innerHeight < 900); - }; - - updateCompactViewport(); - window.addEventListener('resize', updateCompactViewport); - window.addEventListener('orientationchange', updateCompactViewport); - return () => { - window.removeEventListener('resize', updateCompactViewport); - window.removeEventListener('orientationchange', updateCompactViewport); - }; - }, []); - - useEffect(() => { - if (typeof window === 'undefined') return; - window.localStorage.setItem(VISUAL_QUALITY_STORAGE_KEY, visualQualityMode); - window.localStorage.setItem(USER_SETTINGS_STORAGE_KEY, JSON.stringify({ - visualQualityMode, - reducedMotion, - highContrastUi, - audioSpatialMode, - audioMix - })); - }, [audioMix, audioSpatialMode, highContrastUi, reducedMotion, visualQualityMode]); - useEffect(() => { setScenarioAudioProfile(currentScenario.id); }, [currentScenario.id, setScenarioAudioProfile]); @@ -666,36 +572,20 @@ const App: React.FC = () => { setSelectedSystem(sys); }; + // Settings state lives in useUserSettings; announcing the change is the + // screen's job, so the cyclers report back which mode they picked. const cycleVisualQuality = useCallback(() => { - setVisualQualityMode((prevMode) => { - const currentIndex = VISUAL_QUALITY_SEQUENCE.indexOf(prevMode); - const nextMode = VISUAL_QUALITY_SEQUENCE[(currentIndex + 1) % VISUAL_QUALITY_SEQUENCE.length]; - addLog(`Visual FX: ${nextMode}`, 'info'); - return nextMode; - }); - }, []); + addLog(`Visual FX: ${selectNextVisualQuality()}`, 'info'); + }, [addLog, selectNextVisualQuality]); const cycleAudioSpatial = useCallback(() => { - setAudioSpatialMode((prevMode) => { - const currentIndex = AUDIO_SPATIAL_SEQUENCE.indexOf(prevMode); - const nextMode = AUDIO_SPATIAL_SEQUENCE[(currentIndex + 1) % AUDIO_SPATIAL_SEQUENCE.length]; - addLog(`Audio Espacial: ${nextMode}`, 'info'); - return nextMode; - }); - }, []); - - const handleAudioMixChange = useCallback((mix: Partial) => { - setAudioMix((prev) => normalizeUserAudioMix(mix, prev)); - }, []); + addLog(`Audio Espacial: ${selectNextAudioSpatial()}`, 'info'); + }, [addLog, selectNextAudioSpatial]); const resetSettings = useCallback(() => { - setVisualQualityMode('AUTO'); - setReducedMotion(false); - setHighContrastUi(false); - setAudioSpatialMode(DEFAULT_AUDIO_SPATIAL_MODE); - setAudioMix(DEFAULT_USER_AUDIO_MIX); + restoreDefaultSettings(); addLog('Ajustes restablecidos a valores por defecto', 'warning'); - }, []); + }, [addLog, restoreDefaultSettings]); const getGameOverReason = () => { if (gameState === GameState.VICTORY) return { @@ -1220,9 +1110,10 @@ const App: React.FC = () => { - diff --git a/README.md b/README.md index bb13084..5baacf1 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Event Chaos +[![CI](https://github.com/matecodedev/event_chaos/actions/workflows/ci.yml/badge.svg)](https://github.com/matecodedev/event_chaos/actions/workflows/ci.yml) + A browser game about surviving a live production under pressure. You are in the technical booth. Cues stack up, clients change their minds mid-show, equipment fails at the worst possible moment, and every decision costs you something. Event Chaos turns running live events into a management sim — the fantasy is not power, it is holding a show together while it tries to fall apart. @@ -21,20 +23,25 @@ The full art direction is written down in [`docs/ART_BIBLE.md`](docs/ART_BIBLE.m | HUD and menu cinematics | `utils/uiCinematics.ts` | | Mobile overlay safety rules | `utils/mobileUiPolicy.ts` | | Runtime asset mapping | `utils/artAssets.ts` | +| Game rules — pacing, economy, missions, events | `hooks/gameLogic/` | +| Player preferences and viewport | `hooks/useUserSettings.ts`, `hooks/useViewportLayout.ts` | Gameplay is composed from focused panels — missions, achievements, shop, minigames, narrative popups, social feed, early warnings — rather than one monolithic scene. +The rules of the game are pure functions with no React in them, kept in `hooks/gameLogic/` and re-exported through `useGameLogic`, which owns the stateful half. + ## Tech stack | Layer | Technology | | --- | --- | | UI | React + TypeScript | | Build | Vite | +| Styling | Tailwind, compiled at build time | | Icons | Lucide | ## Getting started -Requires Node.js 18 or newer. +Requires Node.js 20 — the version CI and the deploy both run. ```bash npm install @@ -45,17 +52,34 @@ npm run preview # serve the production build The game runs fully offline-first and needs no external AI API key. If one is present in the environment it is treated as optional runtime metadata, never as a requirement. +Google Fonts is the only third-party origin the game touches at runtime. Everything else — styles, textures, portraits — is served from the same origin. + ## Tests -Design rules that break silently — mobile overlay safety, UI cinematics, asset mappings — are covered by regression tests rather than left to review. +Design rules that break silently — mobile overlay safety, UI cinematics, asset mappings — are covered by regression tests rather than left to review. So is accessibility: the faders are the core mechanic and are fully keyboard operable, and a test fails if that stops being true. ```bash -npm run test # regression suite +npm run test # full suite +npm run test:watch # same suite, watching npm run test:playtest # playtest scenarios npm run typecheck -npm run check +npm run check # everything CI runs, in the same order ``` +Logic suites are plain `.test.ts` files on the fast `node` environment. Component suites are `.test.tsx` and opt into a DOM with a `// @vitest-environment jsdom` docblock on the first line. Assertions use Vitest's own matchers, deliberately not `jest-dom`, whose matcher types would need augmentation to keep `tsc --noEmit` clean. + +## Styling + +Tailwind is compiled at build time through `tailwind.config.js` and `postcss.config.js` — never loaded from a CDN. + +Three components assemble class names at runtime with `String.replace`, which the content scanner cannot see. Those classes live in the config's `safelist`, and the comment there names the files to keep in sync. Removing them silently ships uncoloured progress bars and combo indicators. + +## Shipping + +`netlify.toml` carries the build command, the SPA redirect, cache headers and the Content-Security-Policy. Hashed build output and versioned art are cached for a year; `index.html` never is. + +`npm run check:budget` fails the build if `dist/` outgrows its size budget. The limits are loose on purpose — they exist to catch a 19 MB bundle, not to police kilobytes. + --- Built by [MateCode](https://matecode.dev) — websites and custom software. diff --git a/components/AchievementPanel.tsx b/components/AchievementPanel.tsx index 8b2f974..2f5d939 100644 --- a/components/AchievementPanel.tsx +++ b/components/AchievementPanel.tsx @@ -19,17 +19,24 @@ export const AchievementPanel: React.FC = ({ achievements return (
-
+
-

- +

+

diff --git a/components/Button.tsx b/components/Button.tsx index 1582d63..3bcb9f4 100644 --- a/components/Button.tsx +++ b/components/Button.tsx @@ -9,21 +9,29 @@ interface ButtonProps { variant?: UIButtonVariant; disabled?: boolean; className?: string; + /** Required when the button renders an icon only, so it still has an accessible name. */ + ariaLabel?: string; + ariaPressed?: boolean; } -export const Button = ({ - children, - onClick, - variant = 'neutral', +export const Button = ({ + children, + onClick, + variant = 'neutral', disabled = false, - className = '' + className = '', + ariaLabel, + ariaPressed }: ButtonProps) => { const ui = getUIButtonClasses({ variant, disabled }); return (
diff --git a/components/EarlyWarningPanel.tsx b/components/EarlyWarningPanel.tsx index d19c6b4..89595e6 100644 --- a/components/EarlyWarningPanel.tsx +++ b/components/EarlyWarningPanel.tsx @@ -25,16 +25,21 @@ export const EarlyWarningPanel: React.FC = ({ }; return ( -
+
{visibleWarnings.map(warning => (
-
+
-