Skip to content

Commit 486d382

Browse files
committed
game property badges
1 parent 7fb95b5 commit 486d382

File tree

8 files changed

+406
-31
lines changed

8 files changed

+406
-31
lines changed

frontend/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react';
22
import { useGameStore, useAnalysisStore } from './stores';
33
import { ErrorBoundary } from './components/ErrorBoundary';
44
import { Header } from './components/layout/Header';
5+
import { GameInfoBar } from './components/layout/GameInfoBar';
56
import { StatusBar } from './components/layout/StatusBar';
67
import { MainLayout } from './components/layout/MainLayout';
78
import { ConfigModal } from './components/config/ConfigModal';
@@ -47,6 +48,7 @@ export default function App() {
4748
<ErrorBoundary name="App">
4849
<div className="app">
4950
<Header />
51+
<GameInfoBar />
5052
<ErrorBoundary name="MainLayout">
5153
<MainLayout />
5254
</ErrorBoundary>
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
.game-info-bar {
2+
display: flex;
3+
align-items: center;
4+
gap: 16px;
5+
padding: 8px 16px;
6+
background: var(--bg-secondary);
7+
border-bottom: 1px solid var(--border);
8+
font-size: 13px;
9+
color: var(--text-secondary);
10+
}
11+
12+
.game-players {
13+
font-weight: 500;
14+
}
15+
16+
.game-description {
17+
font-style: italic;
18+
opacity: 0.8;
19+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { useGameStore } from '../../stores';
2+
import { isMAIDGame } from '../../types';
3+
import './GameInfoBar.css';
4+
5+
export function GameInfoBar() {
6+
const currentGame = useGameStore((s) => s.currentGame);
7+
8+
if (!currentGame) return null;
9+
10+
// Get players - MAID uses 'agents' instead of 'players'
11+
const players = isMAIDGame(currentGame) ? currentGame.agents : currentGame.players;
12+
13+
return (
14+
<div className="game-info-bar">
15+
<span className="game-players">Players: {players.join(', ')}</span>
16+
{currentGame.description && (
17+
<span className="game-description">{currentGame.description}</span>
18+
)}
19+
</div>
20+
);
21+
}

frontend/src/components/layout/StatusBar.css

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,6 @@
1616
}
1717

1818
.chip {
19-
background: rgba(31, 111, 235, 0.2);
20-
border: 1px solid rgba(31, 111, 235, 0.5);
2119
padding: 6px 10px;
2220
border-radius: 999px;
2321
font-size: 14px;
@@ -26,19 +24,29 @@
2624
gap: 6px;
2725
}
2826

29-
.chip::before {
30-
content: '✓';
27+
.chip.property-chip::before {
28+
margin-right: 2px;
29+
}
30+
31+
.chip.property-chip.yes::before {
32+
content: '\2713';
3133
color: var(--accent-green);
3234
}
3335

34-
.chip.loading::before {
35-
content: '◐';
36-
animation: spin 1s linear infinite;
36+
.chip.property-chip.no::before {
37+
content: '\2717';
38+
color: var(--text-secondary);
39+
}
40+
41+
.chip.property-chip.yes {
42+
background: rgba(74, 222, 128, 0.15);
43+
border: 1px solid rgba(74, 222, 128, 0.4);
3744
}
3845

39-
@keyframes spin {
40-
from { transform: rotate(0deg); }
41-
to { transform: rotate(360deg); }
46+
.chip.property-chip.no {
47+
background: rgba(100, 100, 100, 0.1);
48+
border: 1px solid var(--border);
49+
color: var(--text-secondary);
4250
}
4351

4452
.config-button {

frontend/src/components/layout/StatusBar.tsx

Lines changed: 23 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,34 @@
1-
import { useShallow } from 'zustand/react/shallow';
2-
import { useAnalysisStore, useUIStore } from '../../stores';
3-
import { isAnalysisError } from '../../types';
1+
import { useUIStore } from '../../stores';
2+
import { useGameProperties } from '../../hooks/useGameProperties';
43
import './StatusBar.css';
54

6-
export function StatusBar() {
7-
const { resultsByType, loadingAnalysis } = useAnalysisStore(
8-
useShallow((state) => ({
9-
resultsByType: state.resultsByType,
10-
loadingAnalysis: state.loadingAnalysis,
11-
}))
5+
interface PropertyChipProps {
6+
label: string;
7+
value: boolean | null;
8+
}
9+
10+
function PropertyChip({ label, value }: PropertyChipProps) {
11+
if (value === null) return null;
12+
return (
13+
<span className={`chip property-chip ${value ? 'yes' : 'no'}`}>
14+
{label}
15+
</span>
1216
);
13-
const openConfig = useUIStore((state) => state.openConfig);
17+
}
1418

15-
// Get non-null, non-error results (errors are shown in the analysis section)
16-
const results = Object.entries(resultsByType)
17-
.filter(([, result]) => result !== null && !isAnalysisError(result))
18-
.map(([id, result]) => ({ id, ...result! }));
19+
export function StatusBar() {
20+
const openConfig = useUIStore((state) => state.openConfig);
21+
const properties = useGameProperties();
1922

2023
return (
2124
<footer className="status-bar">
2225
<div className="status-chips">
23-
{loadingAnalysis && <span className="chip loading">Computing...</span>}
24-
{results.map((result) => (
25-
<span key={result.id} className="chip">
26-
{result.summary}
27-
</span>
28-
))}
26+
<PropertyChip label="2-Player" value={properties.twoPlayer} />
27+
<PropertyChip label="Zero-Sum" value={properties.zeroSum} />
28+
<PropertyChip label="Constant-Sum" value={properties.constantSum} />
29+
<PropertyChip label="Symmetric" value={properties.symmetric} />
30+
<PropertyChip label="Perfect Info" value={properties.perfectInformation} />
31+
<PropertyChip label="Deterministic" value={properties.deterministic} />
2932
</div>
3033
<button className="config-button" onClick={openConfig}>Configure</button>
3134
</footer>
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import { useEffect, useState, useMemo } from 'react';
2+
import { useShallow } from 'zustand/react/shallow';
3+
import { useGameStore } from '../stores';
4+
import {
5+
type AnyGame,
6+
type GameSummary,
7+
isNormalFormGame,
8+
isExtensiveFormGame,
9+
isMAIDGame,
10+
} from '../types';
11+
import {
12+
computeZeroSum,
13+
computeConstantSum,
14+
computeSymmetric,
15+
computePerfectInformation,
16+
computeDeterministic,
17+
} from '../lib/gameProperties';
18+
19+
export interface GameProperties {
20+
twoPlayer: boolean | null;
21+
zeroSum: boolean | null;
22+
constantSum: boolean | null;
23+
symmetric: boolean | null;
24+
perfectInformation: boolean | null;
25+
deterministic: boolean | null;
26+
}
27+
28+
type ConvertibleFormat = 'extensive' | 'normal';
29+
30+
/**
31+
* Check if a format is available (either native or via conversion).
32+
*/
33+
function isFormatAvailable(
34+
nativeGame: AnyGame | null,
35+
summary: GameSummary | null,
36+
format: ConvertibleFormat
37+
): boolean {
38+
if (!nativeGame) return false;
39+
40+
// Check if native format matches
41+
if (format === 'normal' && isNormalFormGame(nativeGame)) return true;
42+
if (format === 'extensive' && isExtensiveFormGame(nativeGame)) return true;
43+
44+
// Check if conversion is possible
45+
const conversionInfo = summary?.conversions?.[format];
46+
return conversionInfo?.possible === true;
47+
}
48+
49+
/**
50+
* Get a game in the requested format (native or converted).
51+
*/
52+
function getGameInFormat(
53+
nativeGame: AnyGame | null,
54+
convertedGames: Map<ConvertibleFormat, AnyGame | null>,
55+
format: ConvertibleFormat
56+
): AnyGame | null {
57+
if (!nativeGame) return null;
58+
59+
// Return native if it matches
60+
if (format === 'normal' && isNormalFormGame(nativeGame)) return nativeGame;
61+
if (format === 'extensive' && isExtensiveFormGame(nativeGame)) return nativeGame;
62+
63+
// Return converted
64+
return convertedGames.get(format) ?? null;
65+
}
66+
67+
/**
68+
* Get player count from any game type.
69+
*/
70+
function getPlayerCount(game: AnyGame): number {
71+
if (isMAIDGame(game)) return game.agents.length;
72+
return game.players.length;
73+
}
74+
75+
/**
76+
* Hook that computes game properties, fetching conversions as needed.
77+
*/
78+
export function useGameProperties(): GameProperties {
79+
const { currentGame, currentGameId, games, fetchConverted } = useGameStore(
80+
useShallow((s) => ({
81+
currentGame: s.currentGame,
82+
currentGameId: s.currentGameId,
83+
games: s.games,
84+
fetchConverted: s.fetchConverted,
85+
}))
86+
);
87+
88+
// Get the summary for conversion info
89+
const summary = useMemo(
90+
() => games.find((g) => g.id === currentGameId) ?? null,
91+
[games, currentGameId]
92+
);
93+
94+
// Track converted games
95+
const [convertedGames, setConvertedGames] = useState<Map<ConvertibleFormat, AnyGame | null>>(
96+
new Map()
97+
);
98+
99+
// Determine which formats we need
100+
const needsNormal = useMemo(
101+
() =>
102+
isFormatAvailable(currentGame, summary, 'normal') &&
103+
currentGame !== null &&
104+
!isNormalFormGame(currentGame),
105+
[currentGame, summary]
106+
);
107+
108+
const needsExtensive = useMemo(
109+
() =>
110+
isFormatAvailable(currentGame, summary, 'extensive') &&
111+
currentGame !== null &&
112+
!isExtensiveFormGame(currentGame),
113+
[currentGame, summary]
114+
);
115+
116+
// Fetch conversions when needed
117+
useEffect(() => {
118+
if (!currentGameId) {
119+
setConvertedGames(new Map());
120+
return;
121+
}
122+
123+
const fetchNeeded = async () => {
124+
const newConverted = new Map<ConvertibleFormat, AnyGame | null>();
125+
126+
if (needsNormal) {
127+
const normalGame = await fetchConverted(currentGameId, 'normal');
128+
newConverted.set('normal', normalGame);
129+
}
130+
131+
if (needsExtensive) {
132+
const extensiveGame = await fetchConverted(currentGameId, 'extensive');
133+
newConverted.set('extensive', extensiveGame);
134+
}
135+
136+
setConvertedGames(newConverted);
137+
};
138+
139+
fetchNeeded();
140+
}, [currentGameId, needsNormal, needsExtensive, fetchConverted]);
141+
142+
// Compute properties
143+
return useMemo(() => {
144+
const nullProps: GameProperties = {
145+
twoPlayer: null,
146+
zeroSum: null,
147+
constantSum: null,
148+
symmetric: null,
149+
perfectInformation: null,
150+
deterministic: null,
151+
};
152+
153+
if (!currentGame) return nullProps;
154+
155+
// Player count - available from any game
156+
const twoPlayer = getPlayerCount(currentGame) === 2;
157+
158+
// Get games in needed formats
159+
const normalGame = getGameInFormat(currentGame, convertedGames, 'normal');
160+
const extensiveGame = getGameInFormat(currentGame, convertedGames, 'extensive');
161+
162+
// Compute properties from whichever format is available
163+
// For zero-sum and constant-sum, prefer NFG if available (simpler), else EFG
164+
const zeroSum = normalGame
165+
? computeZeroSum(normalGame)
166+
: extensiveGame
167+
? computeZeroSum(extensiveGame)
168+
: null;
169+
170+
const constantSum = normalGame
171+
? computeConstantSum(normalGame)
172+
: extensiveGame
173+
? computeConstantSum(extensiveGame)
174+
: null;
175+
176+
// Symmetric requires NFG
177+
const symmetric = normalGame ? computeSymmetric(normalGame) : null;
178+
179+
// Perfect information and deterministic require EFG
180+
const perfectInformation = extensiveGame ? computePerfectInformation(extensiveGame) : null;
181+
const deterministic = extensiveGame ? computeDeterministic(extensiveGame) : null;
182+
183+
return {
184+
twoPlayer,
185+
zeroSum,
186+
constantSum,
187+
symmetric,
188+
perfectInformation,
189+
deterministic,
190+
};
191+
}, [currentGame, convertedGames]);
192+
}

0 commit comments

Comments
 (0)