Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
49 changes: 49 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,6 @@ dist-ssr
*.njsproj
*.sln
*.sw?

# Local tooling cache
.atl/
176 changes: 36 additions & 140 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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';
Expand All @@ -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<VisualQualityMode, string> = {
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<UserAudioMix>;
}

interface ActiveCinematicTransition {
id: number;
label: string;
Expand All @@ -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,
Expand Down Expand Up @@ -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<VisualQualityMode>(() => {
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<boolean>(() => {
const storedSettings = loadStoredUserSettings();
return Boolean(storedSettings.reducedMotion);
});
const [highContrastUi, setHighContrastUi] = useState<boolean>(() => {
const storedSettings = loadStoredUserSettings();
return Boolean(storedSettings.highContrastUi);
});
const [audioSpatialMode, setAudioSpatialMode] = useState<AudioSpatialMode>(() => {
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<UserAudioMix>(() => {
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<ActiveCinematicTransition | null>(null);
const [activeImpactFx, setActiveImpactFx] = useState<ActiveImpactFx | null>(null);
const [activeFreezePulse, setActiveFreezePulse] = useState<ActiveFreezePulse | null>(null);
Expand All @@ -226,46 +172,6 @@ const App: React.FC = () => {
const freezeTimeoutRef = useRef<number | null>(null);
const punchTimeoutRef = useRef<number | null>(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]);
Expand Down Expand Up @@ -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<UserAudioMix>) => {
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 {
Expand Down Expand Up @@ -1220,9 +1110,10 @@ const App: React.FC = () => {
<Button
onClick={() => { playClick(); setShowSettings(true); }}
variant="neutral"
ariaLabel="Abrir ajustes"
className="h-12 w-12 flex items-center justify-center !p-0"
>
<Settings className="w-5 h-5" />
<Settings aria-hidden="true" className="w-5 h-5" />
</Button>
<Button
onClick={() => { playClick(); cycleVisualQuality(); }}
Expand All @@ -1238,8 +1129,13 @@ const App: React.FC = () => {
>
AUD {audioSpatialMode.slice(0, 4)}
</Button>
<Button onClick={() => { playClick(); togglePause(); }} variant="neutral" className="h-12 w-12 flex items-center justify-center !p-0">
<Pause className="w-6 h-6" />
<Button
onClick={() => { playClick(); togglePause(); }}
variant="neutral"
ariaLabel="Pausar el show"
className="h-12 w-12 flex items-center justify-center !p-0"
>
<Pause aria-hidden="true" className="w-6 h-6" />
</Button>
</header>

Expand Down
32 changes: 28 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
Expand All @@ -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.
Loading
Loading