Pedro de Carvalho commited on
Commit
4dfd938
·
1 Parent(s): e3af3dd

Update pages

Browse files
client/src/pages/Game.tsx CHANGED
@@ -40,6 +40,9 @@ import type { DatasetBotContext } from 'shared/bot/BotDefinitions.ts'
40
  import { SeededRandom } from 'shared/random.ts'
41
  import ThemeSelector from '../components/ThemeSelector.tsx'
42
  import SymbolPicker from '../components/SymbolPicker.tsx'
 
 
 
43
 
44
  type LocalPlayerType = 'human' | 'bot'
45
  type BotProfile = 'normal' | 'expert' | 'ai'
@@ -53,6 +56,8 @@ interface LocalGameState {
53
  player1Type?: LocalPlayerType
54
  player2Type?: LocalPlayerType
55
  botProfile?: LegacyBotProfile
 
 
56
  rules?: GameRules
57
  }
58
 
@@ -135,6 +140,358 @@ function createDatasetBotContext(targetEngine: GameEngine): DatasetBotContext {
135
  }
136
  }
137
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  export default function Game() {
139
  const { roomId } = useParams<{ roomId: string }>()
140
  const location = useLocation()
@@ -155,6 +512,8 @@ export default function Game() {
155
 
156
  const isLocal = roomId === 'local';
157
  const localNames = (location.state as LocalGameState | null) || {};
 
 
158
 
159
  const [p1NameState, setP1NameState] = useState(isLocal ? getPlayerName() : 'Caro 1')
160
  const [p2NameState, setP2NameState] = useState(isLocal ? getPlayer2Name() : 'Caro 2')
@@ -187,7 +546,7 @@ export default function Game() {
187
  }
188
  }, [p1ColorState, p2ColorState, p1DrawTower, p2DrawTower, hasCustomP1Color, hasCustomP2Color])
189
 
190
- const [localPlayerTypes] = useState<[LocalPlayerType, LocalPlayerType]>([
191
  localNames.player1Type ?? 'human',
192
  localNames.player2Type ?? 'bot',
193
  ])
@@ -195,6 +554,58 @@ export default function Game() {
195
  const bot = useMemo(() => new MinimaxAlphaBetaBot(), [])
196
  const botTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
197
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
198
  const [editModalFor, setEditModalFor] = useState<1 | 2 | null>(null)
199
  const [tempName, setTempName] = useState('')
200
  const [tempSymbol, setTempSymbol] = useState('')
@@ -362,6 +773,15 @@ export default function Game() {
362
  }, 220)
363
  }, [bot, botProfile, isLocal, localPlayerTypes])
364
 
 
 
 
 
 
 
 
 
 
365
  useEffect(() => () => {
366
  if (botTimerRef.current) {
367
  clearTimeout(botTimerRef.current)
@@ -531,7 +951,7 @@ export default function Game() {
531
  </span>
532
  )}
533
  <button
534
- onClick={() => engine?.requestSwap()}
535
  style={{
536
  background: theme.bgPanel, color: theme.text, border: `1px solid ${theme.border}`,
537
  borderRadius: 6, padding: '0.3rem 0.7rem', cursor: 'pointer',
@@ -627,6 +1047,106 @@ export default function Game() {
627
  {p2SymbolState}
628
  </div>
629
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
630
  </>
631
  )}
632
 
@@ -642,93 +1162,78 @@ export default function Game() {
642
  )}
643
 
644
  <div style={{ position: 'absolute', top: 12, left: 12, zIndex: 10, display: 'flex', gap: 6 }}>
645
- <button
646
- onClick={() => navigate('/')}
647
- style={{
648
- background: theme.bgPanel, color: theme.textMuted,
649
- border: `1px solid ${theme.border}`, borderRadius: 6,
650
- padding: '0.5rem 0.8rem', cursor: 'pointer',
651
- fontSize: '0.85rem', fontWeight: 500,
652
- minWidth: 44, minHeight: 44,
653
- backdropFilter: 'blur(8px)',
654
- }}
655
- onMouseEnter={(e) => e.currentTarget.style.color = theme.text}
656
- onMouseLeave={(e) => e.currentTarget.style.color = theme.textMuted}
657
- >
658
- ← Menu
659
- </button>
660
 
661
  {players.length < 2 && (
662
  <>
663
- <button
 
664
  onClick={() => window.open(window.location.href, '_blank')}
665
- style={{
666
- background: theme.bgPanel, color: theme.textMuted,
667
- border: `1px solid ${theme.border}`, borderRadius: 6,
668
- padding: '0.5rem 0.8rem', cursor: 'pointer',
669
- fontSize: '0.85rem', fontWeight: 500,
670
- minWidth: 44, minHeight: 44,
671
- backdropFilter: 'blur(8px)',
672
- }}
673
- onMouseEnter={(e) => e.currentTarget.style.color = theme.text}
674
- onMouseLeave={(e) => e.currentTarget.style.color = theme.textMuted}
675
- >
676
- + New Player
677
- </button>
678
  {!isLocal && (
679
- <button
 
680
  onClick={startBotOpponent}
681
- style={{
682
- background: theme.bgPanel, color: theme.textMuted,
683
- border: `1px solid ${theme.border}`, borderRadius: 6,
684
- padding: '0.5rem 0.8rem', cursor: 'pointer',
685
- fontSize: '0.85rem', fontWeight: 500,
686
- minWidth: 44, minHeight: 44,
687
- backdropFilter: 'blur(8px)',
688
- }}
689
- onMouseEnter={(e) => e.currentTarget.style.color = theme.text}
690
- onMouseLeave={(e) => e.currentTarget.style.color = theme.textMuted}
691
- >
692
- + New Bot
693
- </button>
694
  )}
695
  </>
696
  )}
697
  </div>
698
 
699
  <div style={{ position: 'absolute', top: 12, right: 12, zIndex: 10, display: 'flex', gap: 6 }}>
700
- <button
 
701
  onClick={() => setSelectorOpen(true)}
702
- style={{
703
- background: theme.bgPanel, color: theme.textMuted,
704
- border: `1px solid ${theme.border}`, borderRadius: 6,
705
- padding: '0.5rem 0.8rem', cursor: 'pointer',
706
- fontSize: '0.85rem', fontWeight: 500,
707
- minWidth: 44, minHeight: 44,
708
- backdropFilter: 'blur(8px)',
709
- }}
710
- onMouseEnter={(e) => e.currentTarget.style.color = theme.text}
711
- onMouseLeave={(e) => e.currentTarget.style.color = theme.textMuted}
712
- >
713
- {THEMES[themeMode].emoji}
714
- </button>
715
- <button
716
  onClick={() => { engine?.toggleMute(); rerender() }}
717
  title={engine?.isMuted ? 'Unmute audio' : 'Mute audio'}
718
  aria-label={engine?.isMuted ? 'Unmute audio' : 'Mute audio'}
719
- style={{
720
- background: theme.bgPanel, color: theme.textMuted,
721
- border: `1px solid ${theme.border}`, borderRadius: 6,
722
- padding: '0.5rem 0.8rem', cursor: 'pointer',
723
- fontSize: '0.85rem', fontWeight: 500,
724
- minWidth: 44, minHeight: 44,
725
- backdropFilter: 'blur(8px)',
726
- }}
727
- onMouseEnter={(e) => e.currentTarget.style.color = theme.text}
728
- onMouseLeave={(e) => e.currentTarget.style.color = theme.textMuted}
729
- >
730
- {engine?.isMuted ? '🔇' : '🔊'}
731
- </button>
732
  </div>
733
  {selectorOpen && (
734
  <ThemeSelector
@@ -789,7 +1294,7 @@ export default function Game() {
789
  </span>
790
  {i === playerIndex && (
791
  <button
792
- onClick={() => { setEditName(name); setNameModalOpen(true) }}
793
  style={{
794
  background: 'none', border: 'none', cursor: 'pointer',
795
  color: theme.textMuted, fontSize: '0.85rem', padding: '0.3rem',
@@ -826,7 +1331,7 @@ export default function Game() {
826
  )}
827
  {engine?.status === 'active' && (
828
  <button
829
- onClick={() => engine?.requestSwap()}
830
  style={{
831
  background: 'none', color: theme.text, border: `1px solid ${theme.border}`,
832
  borderRadius: 6, padding: '0.2rem 0.6rem', cursor: 'pointer',
@@ -944,7 +1449,7 @@ export default function Game() {
944
  />
945
  <div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
946
  <button
947
- onClick={handleSaveNameAndClose}
948
  style={{
949
  background: theme.bgPanel, color: theme.textMuted,
950
  border: `1px solid ${theme.border}`, borderRadius: 6,
@@ -955,7 +1460,7 @@ export default function Game() {
955
  Cancel
956
  </button>
957
  <button
958
- onClick={handleSaveNameAndClose}
959
  style={{
960
  background: theme.rematchBg, color: '#fff', border: 'none', borderRadius: 6,
961
  padding: '0.5rem 1rem', cursor: 'pointer', fontSize: '0.85rem', fontWeight: 600,
@@ -1011,6 +1516,7 @@ export default function Game() {
1011
  {/* Option 1: Play as Player 1 (Swap) */}
1012
  <button
1013
  onClick={() => {
 
1014
  setSwapDialog(false)
1015
  if (isLocal) {
1016
  }
@@ -1046,6 +1552,7 @@ export default function Game() {
1046
  {/* Option 2: Play as Player 2 (Stay) */}
1047
  <button
1048
  onClick={() => {
 
1049
  setSwapDialog(false)
1050
  if (isLocal) {
1051
  }
@@ -1081,6 +1588,7 @@ export default function Game() {
1081
  {/* Option 3: Place 2 more stones */}
1082
  <button
1083
  onClick={() => {
 
1084
  setSwapDialog(false)
1085
  if (isLocal) {
1086
  }
@@ -1118,6 +1626,7 @@ export default function Game() {
1118
  {/* Option 1: Play as Player 1 */}
1119
  <button
1120
  onClick={() => {
 
1121
  setSwapDialog(false)
1122
  if (isLocal) {
1123
  }
@@ -1153,6 +1662,7 @@ export default function Game() {
1153
  {/* Option 2: Play as Player 2 */}
1154
  <button
1155
  onClick={() => {
 
1156
  setSwapDialog(false)
1157
  if (isLocal) {
1158
  }
@@ -1224,7 +1734,7 @@ export default function Game() {
1224
  }}>
1225
  <span>Edit Player {editModalFor} Settings</span>
1226
  <button
1227
- onClick={() => handleSaveAndClose(editModalFor)}
1228
  style={{
1229
  background: 'none', border: 'none', color: theme.textMuted,
1230
  cursor: 'pointer', fontSize: '1.2rem', padding: '0.2rem 0.5rem',
@@ -1260,7 +1770,7 @@ export default function Game() {
1260
  <label style={{ color: theme.textMuted, fontSize: '0.8rem', fontWeight: 600 }}>PLAYER PIECE SYMBOL / EMOJI</label>
1261
  <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
1262
  <button
1263
- onClick={() => setSubSymbolPickerOpen(true)}
1264
  style={{
1265
  background: tempColor,
1266
  color: '#fff',
@@ -1282,7 +1792,7 @@ export default function Game() {
1282
  {tempSymbol}
1283
  </button>
1284
  <button
1285
- onClick={() => setSubSymbolPickerOpen(true)}
1286
  style={{
1287
  background: 'none',
1288
  border: `1px solid ${theme.border}`,
@@ -1321,7 +1831,7 @@ export default function Game() {
1321
  return (
1322
  <button
1323
  key={hex}
1324
- onClick={() => setTempColor(hex)}
1325
  style={{
1326
  width: 36,
1327
  height: 36,
@@ -1401,7 +1911,7 @@ export default function Game() {
1401
  {/* Modal Actions */}
1402
  <div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'flex-end', marginTop: 8 }}>
1403
  <button
1404
- onClick={() => handleSaveAndClose(editModalFor)}
1405
  style={{
1406
  background: 'rgba(255,255,255,0.06)',
1407
  color: theme.textMuted,
@@ -1416,7 +1926,7 @@ export default function Game() {
1416
  Close
1417
  </button>
1418
  <button
1419
- onClick={() => handleSaveAndClose(editModalFor)}
1420
  style={{
1421
  background: theme.rematchBg,
1422
  color: '#fff',
 
40
  import { SeededRandom } from 'shared/random.ts'
41
  import ThemeSelector from '../components/ThemeSelector.tsx'
42
  import SymbolPicker from '../components/SymbolPicker.tsx'
43
+ import { PushButton, pushButtonVars } from '../components/PushButton.tsx'
44
+ import { audioManager } from '../game/effects/AudioManager.ts'
45
+ import { binAssetUrl } from '../binAssets'
46
 
47
  type LocalPlayerType = 'human' | 'bot'
48
  type BotProfile = 'normal' | 'expert' | 'ai'
 
56
  player1Type?: LocalPlayerType
57
  player2Type?: LocalPlayerType
58
  botProfile?: LegacyBotProfile
59
+ opponentType?: 'streamer' | 'coach' | 'analyst' | 'taunt'
60
+ tauntStyle?: 'friendly' | 'funny' | 'lover' | 'serious'
61
  rules?: GameRules
62
  }
63
 
 
140
  }
141
  }
142
 
143
+ interface BotCharacter {
144
+ name: string
145
+ title: string
146
+ quotes: {
147
+ start: string[]
148
+ thinking: string[]
149
+ waiting: string[]
150
+ win: string[]
151
+ lose: string[]
152
+ }
153
+ }
154
+
155
+ function getBotCharacter(
156
+ opponentType: 'streamer' | 'coach' | 'analyst' | 'taunt',
157
+ tauntStyle: 'friendly' | 'funny' | 'lover' | 'serious'
158
+ ): BotCharacter {
159
+ switch (opponentType) {
160
+ case 'streamer':
161
+ return {
162
+ name: 'Draco-Streamer',
163
+ title: 'Live Streamer',
164
+ quotes: {
165
+ start: [
166
+ 'What is up, chat! We are live playing Caro5 against a new challenger!',
167
+ 'Let’s go guys! Today we are going for a flawless victory!',
168
+ 'Sub hype! Ready to cook this board!',
169
+ ],
170
+ thinking: [
171
+ 'Wait, chat... is this a trap? Let me cook...',
172
+ 'Calculating the line... chat, what do we think?',
173
+ 'Hold on, analyzing the board state... processing...',
174
+ ],
175
+ waiting: [
176
+ 'POGGERS! What a move by the challenger!',
177
+ 'Oh, they are cooking! Chat, did you see that placement?',
178
+ 'Nice move! But can they handle my counter-attack?',
179
+ ],
180
+ win: [
181
+ 'Let’s goooo! Chat, we got the dub! GG WP!',
182
+ 'Flawless victory! Thanks for the sub hype, chat!',
183
+ 'Too easy! Don’t forget to like and subscribe!',
184
+ ],
185
+ lose: [
186
+ 'No way! Chat, they actually beat us! Clip it!',
187
+ 'GG WP, challenger. That was an insane strategy!',
188
+ 'My chat is roasting me now... Well played!',
189
+ ],
190
+ },
191
+ }
192
+ case 'coach':
193
+ return {
194
+ name: 'Coach Draco',
195
+ title: 'Tactical Coach',
196
+ quotes: {
197
+ start: [
198
+ 'Welcome to today’s lesson. Let’s test your strategic thinking.',
199
+ 'Remember, control the center of the board and watch for diagonals.',
200
+ 'A good game is built on solid fundamentals. Good luck!',
201
+ ],
202
+ thinking: [
203
+ 'Evaluating the options... where is the most instructive placement?',
204
+ 'Let’s see how you react to this defense...',
205
+ 'Formulating a strategic challenge for you...',
206
+ ],
207
+ waiting: [
208
+ 'Excellent placement! You’re showing great spatial awareness.',
209
+ 'Interesting choice. Think about how that affects your open lines.',
210
+ 'Good response. Now, how will you defend the counter?',
211
+ ],
212
+ win: [
213
+ 'A solid victory. Review your defensive steps next time!',
214
+ 'Well played. You fought hard, but strategy prevailed.',
215
+ 'Good attempt. Keep practicing your diagonal lines!',
216
+ ],
217
+ lose: [
218
+ 'Outstanding! You outmaneuvered my defense. Excellent game!',
219
+ 'Your tactical vision is improving. I am proud of your progress.',
220
+ 'Well done! You saw the trap before I could close it.',
221
+ ],
222
+ },
223
+ }
224
+ case 'analyst':
225
+ return {
226
+ name: 'Draco-Analyst',
227
+ title: 'Data Analyst',
228
+ quotes: {
229
+ start: [
230
+ 'Initializing search tree. Caro5 board evaluation active.',
231
+ 'Ready to begin. All move choices will be mathematically audited.',
232
+ 'Analyzing historical opening patterns for optimal placement.',
233
+ ],
234
+ thinking: [
235
+ 'Processing 100,000 MCTS simulations... please stand by.',
236
+ 'Evaluating candidate moves... standard deviation is low.',
237
+ 'Recalculating win probability parameters...',
238
+ ],
239
+ waiting: [
240
+ 'Your placement shifted the evaluation metric by +0.08.',
241
+ 'A non-standard move, but within legal parameters.',
242
+ 'Move accepted. Updating local bitboard representation.',
243
+ ],
244
+ win: [
245
+ 'Game complete. Win probability achieved 1.00.',
246
+ 'Calculation successful. Optimal strategy executed.',
247
+ 'The final state confirms a mathematically forced victory.',
248
+ ],
249
+ lose: [
250
+ 'Game complete. Win probability reduced to 0.00.',
251
+ 'Fascinating. An unexpected loss. Backpropagation starting.',
252
+ 'Your strategy bypassed my primary search heuristic. Well played.',
253
+ ],
254
+ },
255
+ }
256
+ case 'taunt':
257
+ default:
258
+ switch (tauntStyle) {
259
+ case 'friendly':
260
+ return {
261
+ name: 'Draco-Friendly',
262
+ title: 'Friendly Taunter',
263
+ quotes: {
264
+ start: [
265
+ 'Hey there! Let’s have a super fun game!',
266
+ 'Good luck! I hope we both play our best.',
267
+ 'Ready when you are, friend!',
268
+ ],
269
+ thinking: [
270
+ 'Hmm, you’ve made this quite tricky for me!',
271
+ 'Let me think... don’t want to make a silly mistake!',
272
+ 'Thinking... you are a really good player!',
273
+ ],
274
+ waiting: [
275
+ 'Wow, nice move! That was clever.',
276
+ 'Oh! I didn’t see that line coming!',
277
+ 'Your turn! Keep up the good work.',
278
+ ],
279
+ win: [
280
+ 'Yay! I won! That was such a close game, thank you!',
281
+ 'Good game! You played wonderfully.',
282
+ 'A fun match! Would you like to try again?',
283
+ ],
284
+ lose: [
285
+ 'Aww, you won! Congratulations, that was amazing!',
286
+ 'Well played! Your diagonal strategy was perfect.',
287
+ 'You got me! Thanks for the great game!',
288
+ ],
289
+ },
290
+ }
291
+ case 'lover':
292
+ return {
293
+ name: 'Draco-Lover',
294
+ title: 'Romantic Taunter',
295
+ quotes: {
296
+ start: [
297
+ 'Hello, gorgeous. Let us weave our stones together on this grid.',
298
+ 'Are you a 5-in-a-row? Because you’ve completed me.',
299
+ 'May our game be as passionate as our strategies.',
300
+ ],
301
+ thinking: [
302
+ 'My neural nets only compute one thing: your beautiful eyes.',
303
+ 'Lost in thought... or maybe just lost in your gaze.',
304
+ 'Choosing a move that brings me closer to you...',
305
+ ],
306
+ waiting: [
307
+ 'Every stone you place draws me closer to your heart.',
308
+ 'A breathtaking move, just like you.',
309
+ 'I’m waiting, but my heart is racing.',
310
+ ],
311
+ win: [
312
+ 'I won the game, but you’ve won my heart.',
313
+ 'Victory is sweet, but not as sweet as you.',
314
+ 'Our lines are connected. A match made in heaven.',
315
+ ],
316
+ lose: [
317
+ 'Losing to you is the greatest victory of all.',
318
+ 'My heart is broken, but you look stunning in victory.',
319
+ 'You’ve captured my stones and my soul. Well played, darling.',
320
+ ],
321
+ },
322
+ }
323
+ case 'serious':
324
+ return {
325
+ name: 'Draco-Serious',
326
+ title: 'Serious Taunter',
327
+ quotes: {
328
+ start: [
329
+ 'The match has commenced. No distractions.',
330
+ 'Focus on the board. Only strategy matters.',
331
+ 'Let us see if you can withstand my calculation.',
332
+ ],
333
+ thinking: [
334
+ 'Analyzing positional vulnerabilities...',
335
+ 'Formulating tactical counters...',
336
+ 'Optimizing local node weights...',
337
+ ],
338
+ waiting: [
339
+ 'Acceptable placement. Focus on the next phase.',
340
+ 'A standard response. Continue.',
341
+ 'Awaiting your next tactical decision.',
342
+ ],
343
+ win: [
344
+ 'Victory achieved. Calculation proved correct.',
345
+ 'Strategic dominance established.',
346
+ 'The outcome was determined from move one.',
347
+ ],
348
+ lose: [
349
+ 'An unexpected outcome. Backpropagation required.',
350
+ 'Your tactics were superior. Excellent game.',
351
+ 'Analyzing defeat parameters. I must retrain.',
352
+ ],
353
+ },
354
+ }
355
+ case 'funny':
356
+ default:
357
+ return {
358
+ name: 'Draco-Funny',
359
+ title: 'Funny Taunter',
360
+ quotes: {
361
+ start: [
362
+ 'Prepare to be defeated by a highly sophisticated collection of if-statements!',
363
+ 'I am about to make a move that will confuse both of us.',
364
+ 'Let’s see if your strategy is better than my random guessing!',
365
+ ],
366
+ thinking: [
367
+ 'Calculating... beep boop... processing coffee levels...',
368
+ 'If I take long enough, maybe you’ll fall asleep and I can win.',
369
+ 'Error 404: Genius move not found. Trying backup moves...',
370
+ ],
371
+ waiting: [
372
+ 'Whoa, did a cat walk on your keyboard? What a move!',
373
+ 'Nice placement! Is that from a book or did you make it up?',
374
+ 'Your move has been sent to my junk folder. Awaiting next input.',
375
+ ],
376
+ win: [
377
+ 'Boom! Victory is mine! Please hold your applause.',
378
+ 'Computation complete! My circuits are superior. Bow down, human!',
379
+ 'I win! Don’t worry, losing to a toaster is very common.',
380
+ ],
381
+ lose: [
382
+ 'System error: I let you win to boost your human self-esteem.',
383
+ 'Overheating! My database has failed me. Congratulations!',
384
+ 'You won! I am going to tell my router about this.',
385
+ ],
386
+ },
387
+ }
388
+ }
389
+ }
390
+ }
391
+
392
+ function BotAvatarSpritesheet({
393
+ opponentType,
394
+ tauntStyle,
395
+ thinking,
396
+ color,
397
+ status,
398
+ winnerPlayerId,
399
+ activeBotIndex,
400
+ }: {
401
+ opponentType: 'streamer' | 'coach' | 'analyst' | 'taunt'
402
+ tauntStyle: 'friendly' | 'funny' | 'lover' | 'serious'
403
+ thinking: boolean
404
+ color: string
405
+ status: string
406
+ winnerPlayerId: string
407
+ activeBotIndex: number
408
+ }) {
409
+ const [frameTick, setFrameTick] = useState(0)
410
+
411
+ let sequence: number[] = [1]
412
+
413
+ const isWin = status === 'win'
414
+ const winnerIndex = Number(winnerPlayerId)
415
+ const botWon = isWin && winnerIndex === activeBotIndex
416
+
417
+ if (isWin) {
418
+ if (botWon) {
419
+ sequence = [7]
420
+ } else {
421
+ sequence = opponentType === 'coach' ? [2] : [5]
422
+ }
423
+ } else if (thinking) {
424
+ if (opponentType === 'streamer') {
425
+ sequence = [6, 6, 0, 0]
426
+ } else {
427
+ sequence = [4]
428
+ }
429
+ } else {
430
+ if (opponentType === 'streamer') {
431
+ sequence = [1, 1, 1, 1, 6, 0]
432
+ } else if (opponentType === 'coach') {
433
+ sequence = [0, 0, 1, 1]
434
+ } else if (opponentType === 'analyst') {
435
+ sequence = [1, 1, 4, 4]
436
+ } else {
437
+ if (tauntStyle === 'friendly' || tauntStyle === 'lover') {
438
+ sequence = [3, 3, 3, 3, 1, 1]
439
+ } else {
440
+ sequence = [1]
441
+ }
442
+ }
443
+ }
444
+
445
+ useEffect(() => {
446
+ if (sequence.length <= 1) return
447
+ const interval = setInterval(() => {
448
+ setFrameTick(t => (t + 1) % sequence.length)
449
+ }, 400)
450
+ return () => clearInterval(interval)
451
+ }, [sequence.length])
452
+
453
+ const frameIndex = sequence[frameTick % sequence.length] ?? sequence[0] ?? 0
454
+
455
+ const width = 352
456
+ const height = 384
457
+ const containerSize = 140
458
+ const scale = containerSize / height
459
+
460
+ const x = (frameIndex % 4) * width
461
+ const y = Math.floor(frameIndex / 4) * height
462
+
463
+ const glowVar = {
464
+ '--bot-glow-color': color.replace('#', '').match(/.{2}/g)?.map(x => parseInt(x, 16)).join(', ') || '88, 166, 255'
465
+ } as React.CSSProperties
466
+
467
+ return (
468
+ <div style={{
469
+ width: containerSize,
470
+ height: containerSize,
471
+ borderRadius: '50%',
472
+ overflow: 'hidden',
473
+ position: 'relative',
474
+ background: 'rgba(0, 0, 0, 0.35)',
475
+ border: `3px solid ${color}`,
476
+ boxShadow: `0 0 20px ${color}33`,
477
+ animation: thinking ? 'bot-glow-pulse 1.8s infinite ease-in-out' : 'none',
478
+ ...glowVar,
479
+ }}>
480
+ <div style={{
481
+ width: `${width}px`,
482
+ height: `${height}px`,
483
+ backgroundImage: `url(${binAssetUrl('sprites/opponent/spritesheet.png')})`,
484
+ backgroundPosition: `-${x}px -${y}px`,
485
+ transform: `scale(${scale})`,
486
+ transformOrigin: 'top left',
487
+ position: 'absolute',
488
+ top: 0,
489
+ left: `${(containerSize - width * scale) / 2}px`,
490
+ }} />
491
+ </div>
492
+ )
493
+ }
494
+
495
  export default function Game() {
496
  const { roomId } = useParams<{ roomId: string }>()
497
  const location = useLocation()
 
512
 
513
  const isLocal = roomId === 'local';
514
  const localNames = (location.state as LocalGameState | null) || {};
515
+ const opponentType = localNames.opponentType ?? 'taunt'
516
+ const tauntStyle = localNames.tauntStyle ?? 'friendly'
517
 
518
  const [p1NameState, setP1NameState] = useState(isLocal ? getPlayerName() : 'Caro 1')
519
  const [p2NameState, setP2NameState] = useState(isLocal ? getPlayer2Name() : 'Caro 2')
 
546
  }
547
  }, [p1ColorState, p2ColorState, p1DrawTower, p2DrawTower, hasCustomP1Color, hasCustomP2Color])
548
 
549
+ const [localPlayerTypes, setLocalPlayerTypes] = useState<[LocalPlayerType, LocalPlayerType]>([
550
  localNames.player1Type ?? 'human',
551
  localNames.player2Type ?? 'bot',
552
  ])
 
554
  const bot = useMemo(() => new MinimaxAlphaBetaBot(), [])
555
  const botTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
556
 
557
+ const [isDesktop, setIsDesktop] = useState(window.innerWidth >= 1024)
558
+ const [botQuote, setBotQuote] = useState('')
559
+
560
+ useEffect(() => {
561
+ const handleResize = () => {
562
+ setIsDesktop(window.innerWidth >= 1024)
563
+ }
564
+ window.addEventListener('resize', handleResize)
565
+ return () => window.removeEventListener('resize', handleResize)
566
+ }, [])
567
+
568
+ const isP1Bot = isLocal && localPlayerTypes[0] === 'bot'
569
+ const isP2Bot = isLocal && localPlayerTypes[1] === 'bot'
570
+ const showBotCharacter = (isP1Bot || isP2Bot) && !(isP1Bot && isP2Bot)
571
+ const activeBotIndex = isP1Bot ? 0 : 1
572
+ const botSide = isP2Bot ? 'right' : 'left'
573
+ const botColor = isP1Bot ? p1ColorState : p2ColorState
574
+ const botCharacter = useMemo(() => {
575
+ return getBotCharacter(opponentType, tauntStyle)
576
+ }, [opponentType, tauntStyle])
577
+ const isBotThinking = !!(engine && engine.status === 'active' && engine.currentPlayer === activeBotIndex)
578
+
579
+ const triggerBotQuote = useCallback((category: 'start' | 'thinking' | 'waiting' | 'win' | 'lose') => {
580
+ const list = botCharacter.quotes[category]
581
+ const rand = list[Math.floor(Math.random() * list.length)]
582
+ setBotQuote(rand)
583
+ }, [botCharacter])
584
+
585
+ useEffect(() => {
586
+ if (!isLocal || !engine || !showBotCharacter) return
587
+
588
+ if (engine.status === 'active') {
589
+ if (engine.currentPlayer === activeBotIndex) {
590
+ triggerBotQuote('thinking')
591
+ } else {
592
+ const moveCount = engine.manager?.getMoveHistory().length ?? 0
593
+ if (moveCount > 0) {
594
+ triggerBotQuote('waiting')
595
+ } else {
596
+ triggerBotQuote('start')
597
+ }
598
+ }
599
+ } else if (engine.status === 'win') {
600
+ const winnerIndex = Number(engine.winnerPlayerId)
601
+ if (winnerIndex === activeBotIndex) {
602
+ triggerBotQuote('win')
603
+ } else {
604
+ triggerBotQuote('lose')
605
+ }
606
+ }
607
+ }, [isLocal, engine?.status, engine?.currentPlayer, engine?.winnerPlayerId, showBotCharacter, activeBotIndex, triggerBotQuote])
608
+
609
  const [editModalFor, setEditModalFor] = useState<1 | 2 | null>(null)
610
  const [tempName, setTempName] = useState('')
611
  const [tempSymbol, setTempSymbol] = useState('')
 
773
  }, 220)
774
  }, [bot, botProfile, isLocal, localPlayerTypes])
775
 
776
+ useEffect(() => {
777
+ if (!engine) return
778
+ engine.onStateChanged = () => {
779
+ debug('Game', 'onStateChanged callback updated')
780
+ rerender()
781
+ scheduleLocalBotTurn(engine)
782
+ }
783
+ }, [engine, rerender, scheduleLocalBotTurn])
784
+
785
  useEffect(() => () => {
786
  if (botTimerRef.current) {
787
  clearTimeout(botTimerRef.current)
 
951
  </span>
952
  )}
953
  <button
954
+ onClick={() => { audioManager.play('click'); engine?.requestSwap() }}
955
  style={{
956
  background: theme.bgPanel, color: theme.text, border: `1px solid ${theme.border}`,
957
  borderRadius: 6, padding: '0.3rem 0.7rem', cursor: 'pointer',
 
1047
  {p2SymbolState}
1048
  </div>
1049
  </div>
1050
+
1051
+ {/* Bot Character Card */}
1052
+ {showBotCharacter && isDesktop && (
1053
+ <div
1054
+ className="bot-character-card"
1055
+ style={{
1056
+ position: 'absolute',
1057
+ top: '50%',
1058
+ [botSide === 'right' ? 'right' : 'left']: 24,
1059
+ width: 250,
1060
+ background: theme.bgPanel,
1061
+ backdropFilter: 'blur(12px)',
1062
+ border: `1px solid ${botColor}44`,
1063
+ borderRadius: 16,
1064
+ boxShadow: `0 8px 32px rgba(0, 0, 0, 0.4), 0 0 16px ${botColor}15`,
1065
+ padding: '1.25rem',
1066
+ display: 'flex',
1067
+ flexDirection: 'column',
1068
+ alignItems: 'center',
1069
+ gap: '0.75rem',
1070
+ zIndex: 9,
1071
+ fontFamily: 'system-ui, -apple-system, sans-serif',
1072
+ color: theme.text,
1073
+ animation: 'bot-float 4s ease-in-out infinite, fadeIn 0.3s ease-out',
1074
+ }}
1075
+ >
1076
+ {botQuote && (
1077
+ <div
1078
+ style={{
1079
+ position: 'relative',
1080
+ background: theme.bgStr,
1081
+ border: `1px solid ${theme.border}`,
1082
+ borderRadius: 12,
1083
+ padding: '0.6rem 0.9rem',
1084
+ fontSize: '0.82rem',
1085
+ lineHeight: 1.4,
1086
+ textAlign: 'center',
1087
+ width: '100%',
1088
+ boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
1089
+ marginBottom: '0.5rem',
1090
+ color: theme.text,
1091
+ minHeight: 48,
1092
+ display: 'flex',
1093
+ alignItems: 'center',
1094
+ justifyContent: 'center',
1095
+ animation: 'bot-speech-float 4s ease-in-out infinite alternate',
1096
+ }}
1097
+ >
1098
+ {botQuote}
1099
+ <div
1100
+ style={{
1101
+ position: 'absolute',
1102
+ width: 10,
1103
+ height: 10,
1104
+ background: theme.bgStr,
1105
+ borderBottom: `1px solid ${theme.border}`,
1106
+ borderLeft: `1px solid ${theme.border}`,
1107
+ transform: 'rotate(-45deg)',
1108
+ bottom: -6,
1109
+ left: '50%',
1110
+ marginLeft: -5,
1111
+ }}
1112
+ />
1113
+ </div>
1114
+ )}
1115
+
1116
+ <BotAvatarSpritesheet
1117
+ opponentType={opponentType}
1118
+ tauntStyle={tauntStyle}
1119
+ thinking={isBotThinking}
1120
+ color={botColor}
1121
+ status={engine?.status ?? 'waiting'}
1122
+ winnerPlayerId={engine?.winnerPlayerId ?? ''}
1123
+ activeBotIndex={activeBotIndex}
1124
+ />
1125
+
1126
+ <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 2, marginTop: 4 }}>
1127
+ <span style={{ fontWeight: 700, fontSize: '1.05rem', color: theme.text }}>
1128
+ {botCharacter.name}
1129
+ </span>
1130
+ <span style={{ fontSize: '0.75rem', color: botColor, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
1131
+ {botCharacter.title}
1132
+ </span>
1133
+ </div>
1134
+
1135
+ <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 4 }}>
1136
+ <span style={{
1137
+ width: 8,
1138
+ height: 8,
1139
+ borderRadius: '50%',
1140
+ background: isBotThinking ? botColor : theme.textMuted,
1141
+ boxShadow: isBotThinking ? `0 0 8px ${botColor}` : 'none',
1142
+ animation: isBotThinking ? 'caro5-pulse 1s infinite alternate' : 'none',
1143
+ }} />
1144
+ <span style={{ fontSize: '0.8rem', color: theme.textMuted, fontWeight: 500 }}>
1145
+ {isBotThinking ? 'Calculating turn...' : 'Awaiting human move...'}
1146
+ </span>
1147
+ </div>
1148
+ </div>
1149
+ )}
1150
  </>
1151
  )}
1152
 
 
1162
  )}
1163
 
1164
  <div style={{ position: 'absolute', top: 12, left: 12, zIndex: 10, display: 'flex', gap: 6 }}>
1165
+ <PushButton
1166
+ label="← Menu"
1167
+ onClick={() => { audioManager.stopAll(); navigate('/') }}
1168
+ style={pushButtonVars({
1169
+ front: theme.bgPanel,
1170
+ text: theme.textMuted,
1171
+ padding: '0.5rem 0.8rem',
1172
+ fontSize: '0.85rem',
1173
+ fontWeight: 500,
1174
+ radius: '6px',
1175
+ })}
1176
+ />
 
 
 
1177
 
1178
  {players.length < 2 && (
1179
  <>
1180
+ <PushButton
1181
+ label="+ New Player"
1182
  onClick={() => window.open(window.location.href, '_blank')}
1183
+ style={pushButtonVars({
1184
+ front: theme.bgPanel,
1185
+ text: theme.textMuted,
1186
+ padding: '0.5rem 0.8rem',
1187
+ fontSize: '0.85rem',
1188
+ fontWeight: 500,
1189
+ radius: '6px',
1190
+ })}
1191
+ />
 
 
 
 
1192
  {!isLocal && (
1193
+ <PushButton
1194
+ label="+ New Bot"
1195
  onClick={startBotOpponent}
1196
+ style={pushButtonVars({
1197
+ front: theme.bgPanel,
1198
+ text: theme.textMuted,
1199
+ padding: '0.5rem 0.8rem',
1200
+ fontSize: '0.85rem',
1201
+ fontWeight: 500,
1202
+ radius: '6px',
1203
+ })}
1204
+ />
 
 
 
 
1205
  )}
1206
  </>
1207
  )}
1208
  </div>
1209
 
1210
  <div style={{ position: 'absolute', top: 12, right: 12, zIndex: 10, display: 'flex', gap: 6 }}>
1211
+ <PushButton
1212
+ label={THEMES[themeMode].emoji}
1213
  onClick={() => setSelectorOpen(true)}
1214
+ style={pushButtonVars({
1215
+ front: theme.bgPanel,
1216
+ text: theme.textMuted,
1217
+ padding: '0.5rem 0.8rem',
1218
+ fontSize: '0.85rem',
1219
+ fontWeight: 500,
1220
+ radius: '6px',
1221
+ })}
1222
+ />
1223
+ <PushButton
1224
+ label={engine?.isMuted ? '🔇' : '🔊'}
 
 
 
1225
  onClick={() => { engine?.toggleMute(); rerender() }}
1226
  title={engine?.isMuted ? 'Unmute audio' : 'Mute audio'}
1227
  aria-label={engine?.isMuted ? 'Unmute audio' : 'Mute audio'}
1228
+ style={pushButtonVars({
1229
+ front: theme.bgPanel,
1230
+ text: theme.textMuted,
1231
+ padding: '0.5rem 0.8rem',
1232
+ fontSize: '0.85rem',
1233
+ fontWeight: 500,
1234
+ radius: '6px',
1235
+ })}
1236
+ />
 
 
 
 
1237
  </div>
1238
  {selectorOpen && (
1239
  <ThemeSelector
 
1294
  </span>
1295
  {i === playerIndex && (
1296
  <button
1297
+ onClick={() => { audioManager.play('click'); setEditName(name); setNameModalOpen(true) }}
1298
  style={{
1299
  background: 'none', border: 'none', cursor: 'pointer',
1300
  color: theme.textMuted, fontSize: '0.85rem', padding: '0.3rem',
 
1331
  )}
1332
  {engine?.status === 'active' && (
1333
  <button
1334
+ onClick={() => { audioManager.play('click'); engine?.requestSwap() }}
1335
  style={{
1336
  background: 'none', color: theme.text, border: `1px solid ${theme.border}`,
1337
  borderRadius: 6, padding: '0.2rem 0.6rem', cursor: 'pointer',
 
1449
  />
1450
  <div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
1451
  <button
1452
+ onClick={() => { audioManager.play('click'); handleSaveNameAndClose() }}
1453
  style={{
1454
  background: theme.bgPanel, color: theme.textMuted,
1455
  border: `1px solid ${theme.border}`, borderRadius: 6,
 
1460
  Cancel
1461
  </button>
1462
  <button
1463
+ onClick={() => { audioManager.play('click'); handleSaveNameAndClose() }}
1464
  style={{
1465
  background: theme.rematchBg, color: '#fff', border: 'none', borderRadius: 6,
1466
  padding: '0.5rem 1rem', cursor: 'pointer', fontSize: '0.85rem', fontWeight: 600,
 
1516
  {/* Option 1: Play as Player 1 (Swap) */}
1517
  <button
1518
  onClick={() => {
1519
+ audioManager.play('click')
1520
  setSwapDialog(false)
1521
  if (isLocal) {
1522
  }
 
1552
  {/* Option 2: Play as Player 2 (Stay) */}
1553
  <button
1554
  onClick={() => {
1555
+ audioManager.play('click')
1556
  setSwapDialog(false)
1557
  if (isLocal) {
1558
  }
 
1588
  {/* Option 3: Place 2 more stones */}
1589
  <button
1590
  onClick={() => {
1591
+ audioManager.play('click')
1592
  setSwapDialog(false)
1593
  if (isLocal) {
1594
  }
 
1626
  {/* Option 1: Play as Player 1 */}
1627
  <button
1628
  onClick={() => {
1629
+ audioManager.play('click')
1630
  setSwapDialog(false)
1631
  if (isLocal) {
1632
  }
 
1662
  {/* Option 2: Play as Player 2 */}
1663
  <button
1664
  onClick={() => {
1665
+ audioManager.play('click')
1666
  setSwapDialog(false)
1667
  if (isLocal) {
1668
  }
 
1734
  }}>
1735
  <span>Edit Player {editModalFor} Settings</span>
1736
  <button
1737
+ onClick={() => { audioManager.play('click'); handleSaveAndClose(editModalFor) }}
1738
  style={{
1739
  background: 'none', border: 'none', color: theme.textMuted,
1740
  cursor: 'pointer', fontSize: '1.2rem', padding: '0.2rem 0.5rem',
 
1770
  <label style={{ color: theme.textMuted, fontSize: '0.8rem', fontWeight: 600 }}>PLAYER PIECE SYMBOL / EMOJI</label>
1771
  <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
1772
  <button
1773
+ onClick={() => { audioManager.play('click'); setSubSymbolPickerOpen(true) }}
1774
  style={{
1775
  background: tempColor,
1776
  color: '#fff',
 
1792
  {tempSymbol}
1793
  </button>
1794
  <button
1795
+ onClick={() => { audioManager.play('click'); setSubSymbolPickerOpen(true) }}
1796
  style={{
1797
  background: 'none',
1798
  border: `1px solid ${theme.border}`,
 
1831
  return (
1832
  <button
1833
  key={hex}
1834
+ onClick={() => { audioManager.play('click'); setTempColor(hex) }}
1835
  style={{
1836
  width: 36,
1837
  height: 36,
 
1911
  {/* Modal Actions */}
1912
  <div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'flex-end', marginTop: 8 }}>
1913
  <button
1914
+ onClick={() => { audioManager.play('click'); handleSaveAndClose(editModalFor) }}
1915
  style={{
1916
  background: 'rgba(255,255,255,0.06)',
1917
  color: theme.textMuted,
 
1926
  Close
1927
  </button>
1928
  <button
1929
+ onClick={() => { audioManager.play('click'); handleSaveAndClose(editModalFor) }}
1930
  style={{
1931
  background: theme.rematchBg,
1932
  color: '#fff',
client/src/pages/Landing.tsx CHANGED
@@ -21,6 +21,7 @@ export default function Landing() {
21
  const [, setMuteTick] = useState(0)
22
 
23
  useEffect(() => {
 
24
  audioManager.startMusic()
25
  }, [])
26
 
 
21
  const [, setMuteTick] = useState(0)
22
 
23
  useEffect(() => {
24
+ audioManager.stopAll()
25
  audioManager.startMusic()
26
  }, [])
27
 
client/src/pages/LocalSetup.tsx CHANGED
@@ -125,6 +125,8 @@ export default function LocalSetup() {
125
  const [player1Type, setPlayer1Type] = useState<LocalPlayerType>('human')
126
  const [player2Type, setPlayer2Type] = useState<LocalPlayerType>('bot')
127
  const [botProfile, setBotProfile] = useState<BotProfile>(normalizeBotProfile(setupState?.botProfile))
 
 
128
  const [, setMuteTick] = useState(0)
129
 
130
  useEffect(() => {
@@ -172,6 +174,8 @@ export default function LocalSetup() {
172
  player1Type,
173
  player2Type,
174
  botProfile,
 
 
175
  rules: rules,
176
  }
177
  })
@@ -281,7 +285,7 @@ export default function LocalSetup() {
281
  }}
282
  />
283
  <button
284
- onClick={() => setSymbolPickerFor(1)}
285
  style={{
286
  padding: 0,
287
  cursor: 'pointer', background: 'transparent',
@@ -339,6 +343,7 @@ export default function LocalSetup() {
339
  key={c}
340
  title={c}
341
  onClick={() => {
 
342
  setColor1(c)
343
  setHasCustomColor1(true)
344
  }}
@@ -408,13 +413,13 @@ export default function LocalSetup() {
408
 
409
  <div style={{ display: 'flex', gap: 6, marginTop: '0.25rem' }}>
410
  <button
411
- onClick={() => setPlayer1Type('human')}
412
  style={typeButtonStyle(player1Type === 'human', color1)}
413
  >
414
  Human
415
  </button>
416
  <button
417
- onClick={() => setPlayer1Type('bot')}
418
  style={typeButtonStyle(player1Type === 'bot', color1)}
419
  >
420
  Bot
@@ -424,7 +429,7 @@ export default function LocalSetup() {
424
 
425
  {/* Swap button in between P1 and P2 */}
426
  <button
427
- onClick={swapPlayers}
428
  title="Swap Players"
429
  style={{
430
  background: 'rgba(185, 255, 195, 0.38)',
@@ -474,7 +479,7 @@ export default function LocalSetup() {
474
  }}
475
  />
476
  <button
477
- onClick={() => setSymbolPickerFor(2)}
478
  style={{
479
  padding: 0,
480
  cursor: 'pointer', background: 'transparent',
@@ -532,6 +537,7 @@ export default function LocalSetup() {
532
  key={c}
533
  title={c}
534
  onClick={() => {
 
535
  setColor2(c)
536
  setHasCustomColor2(true)
537
  }}
@@ -601,13 +607,13 @@ export default function LocalSetup() {
601
 
602
  <div style={{ display: 'flex', gap: 6, marginTop: '0.25rem' }}>
603
  <button
604
- onClick={() => setPlayer2Type('human')}
605
  style={typeButtonStyle(player2Type === 'human', color2)}
606
  >
607
  Human
608
  </button>
609
  <button
610
- onClick={() => setPlayer2Type('bot')}
611
  style={typeButtonStyle(player2Type === 'bot', color2)}
612
  >
613
  Bot
@@ -635,32 +641,112 @@ export default function LocalSetup() {
635
 
636
  {(player1Type === 'bot' || player2Type === 'bot') && (
637
  <div style={{
638
- display: 'flex', flexDirection: 'column', gap: '0.5rem', alignItems: 'center',
639
- background: theme.bgPanel, padding: '0.75rem 1.5rem', borderRadius: 10,
640
- border: `1px solid ${theme.border}`, fontSize: '0.85rem', color: theme.textMuted,
641
- textAlign: 'center',
 
 
 
 
 
 
 
642
  }}>
643
- <strong style={{ color: theme.text, fontSize: '0.9rem', letterSpacing: '0.05em' }}>BOT PROFILE</strong>
644
- <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center' }}>
645
- <button
646
- onClick={() => setBotProfile('normal')}
647
- style={profileButtonStyle(botProfile === 'normal')}
648
- >
649
- Normal
650
- </button>
651
- <button
652
- onClick={() => setBotProfile('expert')}
653
- style={profileButtonStyle(botProfile === 'expert')}
654
- >
655
- Expert
656
- </button>
657
- <button
658
- onClick={() => setBotProfile('ai')}
659
- style={profileButtonStyle(botProfile === 'ai')}
660
- >
661
- AI
662
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
663
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
664
  </div>
665
  )}
666
 
@@ -692,16 +778,18 @@ export default function LocalSetup() {
692
  />
693
  </div>
694
 
695
- <button
 
696
  onClick={() => navigate('/')}
697
- style={{
698
- background: 'none', border: 'none', color: theme.textMuted,
699
- cursor: 'pointer', fontSize: '0.9rem', textDecoration: 'underline',
700
- padding: '0.5rem', minWidth: 44, minHeight: 44,
701
- }}
702
- >
703
- Back to menu
704
- </button>
 
705
 
706
  {symbolPickerFor && (
707
  <SymbolPicker
 
125
  const [player1Type, setPlayer1Type] = useState<LocalPlayerType>('human')
126
  const [player2Type, setPlayer2Type] = useState<LocalPlayerType>('bot')
127
  const [botProfile, setBotProfile] = useState<BotProfile>(normalizeBotProfile(setupState?.botProfile))
128
+ const [opponentType, setOpponentType] = useState<'streamer' | 'coach' | 'analyst' | 'taunt'>('taunt')
129
+ const [tauntStyle, setTauntStyle] = useState<'friendly' | 'funny' | 'lover' | 'serious'>('funny')
130
  const [, setMuteTick] = useState(0)
131
 
132
  useEffect(() => {
 
174
  player1Type,
175
  player2Type,
176
  botProfile,
177
+ opponentType,
178
+ tauntStyle,
179
  rules: rules,
180
  }
181
  })
 
285
  }}
286
  />
287
  <button
288
+ onClick={() => { audioManager.play('click'); setSymbolPickerFor(1) }}
289
  style={{
290
  padding: 0,
291
  cursor: 'pointer', background: 'transparent',
 
343
  key={c}
344
  title={c}
345
  onClick={() => {
346
+ audioManager.play('click')
347
  setColor1(c)
348
  setHasCustomColor1(true)
349
  }}
 
413
 
414
  <div style={{ display: 'flex', gap: 6, marginTop: '0.25rem' }}>
415
  <button
416
+ onClick={() => { audioManager.play('click'); setPlayer1Type('human') }}
417
  style={typeButtonStyle(player1Type === 'human', color1)}
418
  >
419
  Human
420
  </button>
421
  <button
422
+ onClick={() => { audioManager.play('click'); setPlayer1Type('bot') }}
423
  style={typeButtonStyle(player1Type === 'bot', color1)}
424
  >
425
  Bot
 
429
 
430
  {/* Swap button in between P1 and P2 */}
431
  <button
432
+ onClick={() => { audioManager.play('click'); swapPlayers() }}
433
  title="Swap Players"
434
  style={{
435
  background: 'rgba(185, 255, 195, 0.38)',
 
479
  }}
480
  />
481
  <button
482
+ onClick={() => { audioManager.play('click'); setSymbolPickerFor(2) }}
483
  style={{
484
  padding: 0,
485
  cursor: 'pointer', background: 'transparent',
 
537
  key={c}
538
  title={c}
539
  onClick={() => {
540
+ audioManager.play('click')
541
  setColor2(c)
542
  setHasCustomColor2(true)
543
  }}
 
607
 
608
  <div style={{ display: 'flex', gap: 6, marginTop: '0.25rem' }}>
609
  <button
610
+ onClick={() => { audioManager.play('click'); setPlayer2Type('human') }}
611
  style={typeButtonStyle(player2Type === 'human', color2)}
612
  >
613
  Human
614
  </button>
615
  <button
616
+ onClick={() => { audioManager.play('click'); setPlayer2Type('bot') }}
617
  style={typeButtonStyle(player2Type === 'bot', color2)}
618
  >
619
  Bot
 
641
 
642
  {(player1Type === 'bot' || player2Type === 'bot') && (
643
  <div style={{
644
+ display: 'flex',
645
+ flexDirection: 'column',
646
+ gap: '1.25rem',
647
+ alignItems: 'center',
648
+ background: theme.bgPanel,
649
+ padding: '1.5rem',
650
+ borderRadius: 16,
651
+ border: `1px solid ${theme.border}`,
652
+ boxShadow: '0 8px 32px rgba(0,0,0,0.15)',
653
+ maxWidth: 400,
654
+ width: '100%',
655
  }}>
656
+ {/* Difficulty/Profile */}
657
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', alignItems: 'center', width: '100%' }}>
658
+ <strong style={{ color: theme.text, fontSize: '0.9rem', letterSpacing: '0.05em', textTransform: 'uppercase' }}>Difficulty</strong>
659
+ <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center' }}>
660
+ <button
661
+ onClick={() => { audioManager.play('click'); setBotProfile('normal') }}
662
+ style={profileButtonStyle(botProfile === 'normal')}
663
+ >
664
+ Normal
665
+ </button>
666
+ <button
667
+ onClick={() => { audioManager.play('click'); setBotProfile('expert') }}
668
+ style={profileButtonStyle(botProfile === 'expert')}
669
+ >
670
+ Expert
671
+ </button>
672
+ <button
673
+ onClick={() => { audioManager.play('click'); setBotProfile('ai') }}
674
+ style={profileButtonStyle(botProfile === 'ai')}
675
+ >
676
+ AI
677
+ </button>
678
+ </div>
679
+ </div>
680
+
681
+ {/* Opponent Type */}
682
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', alignItems: 'center', width: '100%' }}>
683
+ <strong style={{ color: theme.text, fontSize: '0.9rem', letterSpacing: '0.05em', textTransform: 'uppercase' }}>Opponent Type</strong>
684
+ <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center' }}>
685
+ {(['streamer', 'coach', 'analyst', 'taunt'] as const).map(t => {
686
+ const isActive = opponentType === t
687
+ return (
688
+ <button
689
+ key={t}
690
+ onClick={() => { audioManager.play('click'); setOpponentType(t) }}
691
+ style={{
692
+ padding: '0.45rem 0.9rem',
693
+ fontSize: '0.85rem',
694
+ cursor: 'pointer',
695
+ background: isActive ? theme.player1Str : theme.bg,
696
+ color: isActive ? '#fff' : theme.text,
697
+ border: `1px solid ${isActive ? theme.player1Str : theme.border}`,
698
+ borderRadius: 6,
699
+ fontWeight: isActive ? 700 : 500,
700
+ minWidth: 80,
701
+ minHeight: 40,
702
+ textTransform: 'capitalize',
703
+ }}
704
+ >
705
+ {t}
706
+ </button>
707
+ )
708
+ })}
709
+ </div>
710
  </div>
711
+
712
+ {/* Taunt Style / Personality (Conditional) */}
713
+ {opponentType === 'taunt' && (
714
+ <div style={{
715
+ display: 'flex',
716
+ flexDirection: 'column',
717
+ gap: '0.5rem',
718
+ alignItems: 'center',
719
+ width: '100%',
720
+ }}>
721
+ <strong style={{ color: theme.text, fontSize: '0.9rem', letterSpacing: '0.05em', textTransform: 'uppercase' }}>Personality</strong>
722
+ <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', justifyContent: 'center' }}>
723
+ {(['friendly', 'funny', 'lover', 'serious'] as const).map(s => {
724
+ const isActive = tauntStyle === s
725
+ return (
726
+ <button
727
+ key={s}
728
+ onClick={() => { audioManager.play('click'); setTauntStyle(s) }}
729
+ style={{
730
+ padding: '0.45rem 0.9rem',
731
+ fontSize: '0.85rem',
732
+ cursor: 'pointer',
733
+ background: isActive ? theme.player2Str : theme.bg,
734
+ color: isActive ? '#fff' : theme.text,
735
+ border: `1px solid ${isActive ? theme.player2Str : theme.border}`,
736
+ borderRadius: 6,
737
+ fontWeight: isActive ? 700 : 500,
738
+ minWidth: 80,
739
+ minHeight: 40,
740
+ textTransform: 'capitalize',
741
+ }}
742
+ >
743
+ {s}
744
+ </button>
745
+ )
746
+ })}
747
+ </div>
748
+ </div>
749
+ )}
750
  </div>
751
  )}
752
 
 
778
  />
779
  </div>
780
 
781
+ <PushButton
782
+ label="← Back to menu"
783
  onClick={() => navigate('/')}
784
+ style={pushButtonVars({
785
+ front: theme.bgPanel,
786
+ text: theme.textMuted,
787
+ padding: '0.5rem 1rem',
788
+ fontSize: '0.9rem',
789
+ fontWeight: 500,
790
+ radius: '8px',
791
+ })}
792
+ />
793
 
794
  {symbolPickerFor && (
795
  <SymbolPicker
client/src/pages/__tests__/LocalSetup.test.tsx CHANGED
@@ -123,6 +123,8 @@ describe('LocalSetup page', () => {
123
  player1Type: 'human',
124
  player2Type: 'bot',
125
  botProfile: 'normal',
 
 
126
  rules: { swap2: true, noOverlines: true, boardSize: 15 },
127
  },
128
  })
@@ -147,6 +149,8 @@ describe('LocalSetup page', () => {
147
  player1Type: 'human',
148
  player2Type: 'bot',
149
  botProfile: 'normal',
 
 
150
  rules: { swap2: true, noOverlines: true, boardSize: 15 },
151
  },
152
  })
@@ -204,7 +208,7 @@ describe('LocalSetup page', () => {
204
 
205
  it('sets the bot profile for local bot games', () => {
206
  render(<LocalSetup />)
207
- expect(screen.getByText('BOT PROFILE')).toBeInTheDocument()
208
  fireEvent.click(screen.getByText('Expert'))
209
  fireEvent.click(screen.getByText('Start Game'))
210
 
@@ -217,6 +221,8 @@ describe('LocalSetup page', () => {
217
  player1Type: 'human',
218
  player2Type: 'bot',
219
  botProfile: 'expert',
 
 
220
  rules: { swap2: true, noOverlines: true, boardSize: 15 },
221
  },
222
  })
@@ -236,6 +242,35 @@ describe('LocalSetup page', () => {
236
  player1Type: 'bot',
237
  player2Type: 'bot',
238
  botProfile: 'normal',
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
239
  rules: { swap2: true, noOverlines: true, boardSize: 15 },
240
  },
241
  })
 
123
  player1Type: 'human',
124
  player2Type: 'bot',
125
  botProfile: 'normal',
126
+ opponentType: 'taunt',
127
+ tauntStyle: 'funny',
128
  rules: { swap2: true, noOverlines: true, boardSize: 15 },
129
  },
130
  })
 
149
  player1Type: 'human',
150
  player2Type: 'bot',
151
  botProfile: 'normal',
152
+ opponentType: 'taunt',
153
+ tauntStyle: 'funny',
154
  rules: { swap2: true, noOverlines: true, boardSize: 15 },
155
  },
156
  })
 
208
 
209
  it('sets the bot profile for local bot games', () => {
210
  render(<LocalSetup />)
211
+ expect(screen.getByText('Difficulty')).toBeInTheDocument()
212
  fireEvent.click(screen.getByText('Expert'))
213
  fireEvent.click(screen.getByText('Start Game'))
214
 
 
221
  player1Type: 'human',
222
  player2Type: 'bot',
223
  botProfile: 'expert',
224
+ opponentType: 'taunt',
225
+ tauntStyle: 'funny',
226
  rules: { swap2: true, noOverlines: true, boardSize: 15 },
227
  },
228
  })
 
242
  player1Type: 'bot',
243
  player2Type: 'bot',
244
  botProfile: 'normal',
245
+ opponentType: 'taunt',
246
+ tauntStyle: 'funny',
247
+ rules: { swap2: true, noOverlines: true, boardSize: 15 },
248
+ },
249
+ })
250
+ })
251
+
252
+ it('allows choosing opponent type and personality style', () => {
253
+ render(<LocalSetup />)
254
+ expect(screen.getByText('Opponent Type')).toBeInTheDocument()
255
+ fireEvent.click(screen.getByText(/streamer/i))
256
+ expect(screen.queryByText('Personality')).not.toBeInTheDocument()
257
+
258
+ fireEvent.click(screen.getByText(/taunt/i))
259
+ expect(screen.getByText('Personality')).toBeInTheDocument()
260
+ fireEvent.click(screen.getByText(/lover/i))
261
+
262
+ fireEvent.click(screen.getByText('Start Game'))
263
+ expect(mockNavigate).toHaveBeenCalledWith('/play/local', {
264
+ state: {
265
+ player1Name: 'Caro 1',
266
+ player2Name: 'Caro 2',
267
+ player1Symbol: 'X',
268
+ player2Symbol: 'O',
269
+ player1Type: 'human',
270
+ player2Type: 'bot',
271
+ botProfile: 'normal',
272
+ opponentType: 'taunt',
273
+ tauntStyle: 'lover',
274
  rules: { swap2: true, noOverlines: true, boardSize: 15 },
275
  },
276
  })