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
28 changes: 25 additions & 3 deletions frontend/app/components/matchmaking/AiPersonalityModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import React from "react";
import { useMatchmakingContext, AiPersonality } from "@/context/matchmakingContext";
import { getChessVariantById } from "@/lib/chessVariants";

interface AiPersonalityModalProps {
isOpen: boolean;
Expand Down Expand Up @@ -102,7 +103,9 @@ export function AiPersonalityModal({
onClose,
onConfirm,
}: AiPersonalityModalProps) {
const { aiPersonality, setAiPersonality } = useMatchmakingContext();
const { aiPersonality, setAiPersonality, chessVariant } =
useMatchmakingContext();
const selectedVariant = getChessVariantById(chessVariant);

if (!isOpen) return null;

Expand Down Expand Up @@ -141,13 +144,32 @@ export function AiPersonalityModal({
{/* Header */}
<div className="text-center space-y-1">
<h2 className="text-2xl font-bold text-white tracking-wide">
Choose Your AI Co-Pilot
Finalize Match Setup
</h2>
<p className="text-gray-400 text-sm">
Select a personality style for your AI co-pilot before the match begins.
Lock in your preferred chess format and AI co-pilot before matchmaking starts.
</p>
</div>

<div className="rounded-2xl border border-cyan-400/20 bg-cyan-500/5 p-4">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-cyan-300/80">
Selected Variant
</p>
<h3 className="mt-1 text-lg font-semibold text-white">
{selectedVariant.label}
</h3>
<p className="mt-1 text-sm text-gray-300">
{selectedVariant.description}
</p>
</div>
<span className="rounded-full border border-cyan-400/30 bg-cyan-500/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-cyan-100">
{selectedVariant.averageGameTime}
</span>
</div>
</div>

{/* Personality cards */}
<div className="space-y-3">
{PERSONALITIES.map((option) => {
Expand Down
34 changes: 27 additions & 7 deletions frontend/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import { useMatchmaking } from "@/hook/useMatchmaking";
import { useRouter } from "next/navigation";
import { useMatchmakingContext } from "@/context/matchmakingContext";
import { Web3StatusBar } from "@/components/Web3StatusBar";
import { ChessVariantSelector } from "@/components/ChessVariantSelector";
import { getChessVariantById } from "@/lib/chessVariants";

export default function Home() {
const [game] = useState(new Chess());
Expand All @@ -37,7 +39,9 @@ export default function Home() {
const PLAYER_COUNT_ENDPOINT = `${API_BASE}/v1/players/online`;
const [isPersonalityModalOpen, setIsPersonalityModalOpen] = useState(false);

const { aiPersonality } = useMatchmakingContext();
const { aiPersonality, chessVariant, setChessVariant } =
useMatchmakingContext();
const selectedVariant = getChessVariantById(chessVariant);

const {
status: matchmakingStatus,
Expand Down Expand Up @@ -223,11 +227,16 @@ export default function Home() {
<RiAliensFill className="text-2xl text-white filter drop-shadow-md" />
)}
</div>
<h2 className="text-xl font-bold text-white tracking-wide">
{gameMode === "online"
? onlineStatusLabel()
: "Playing vs Bot"}
</h2>
<div>
<h2 className="text-xl font-bold text-white tracking-wide">
{gameMode === "online"
? onlineStatusLabel()
: "Playing vs Bot"}
</h2>
<p className="mt-1 text-sm text-cyan-100/80">
Variant: {selectedVariant.label} / {selectedVariant.averageGameTime}
</p>
</div>
</div>

<button
Expand All @@ -254,6 +263,9 @@ export default function Home() {
<p className="text-gray-300 text-sm" aria-live="polite">
{onlinePlayerCount} Players online
</p>
<p className="text-cyan-100 text-xs uppercase tracking-[0.24em]">
Queueing for {selectedVariant.label}
</p>

<button
onClick={handleExit}
Expand Down Expand Up @@ -291,7 +303,15 @@ export default function Home() {

{/* Game Modes Section */}
<div className="flex flex-col justify-center space-y-6 max-w-[500px] w-full order-1 md:order-2" role="region" aria-label="Game mode selection">
{!gameMode && <GameModeButtons setGameMode={handleSetGameMode} />}
{!gameMode && (
<>
<GameModeButtons setGameMode={handleSetGameMode} />
<ChessVariantSelector
selectedVariant={chessVariant}
onSelect={setChessVariant}
/>
</>
)}
</div>
</div>
</div>
Expand Down
114 changes: 114 additions & 0 deletions frontend/components/ChessVariantSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"use client";

import React from "react";
import { type ChessVariant, CHESS_VARIANTS } from "@/lib/chessVariants";
import { cn } from "@/lib/utils";

interface ChessVariantSelectorProps {
selectedVariant: ChessVariant;
onSelect: (variant: ChessVariant) => void;
}

/**
* Lets players choose the chess format before creating a bot or online match.
*/
export function ChessVariantSelector({
selectedVariant,
onSelect,
}: ChessVariantSelectorProps) {
return (
<section
aria-labelledby="match-variant-heading"
className="rounded-[28px] border border-white/10 bg-[radial-gradient(circle_at_top,rgba(34,211,238,0.12),transparent_42%),linear-gradient(180deg,rgba(17,24,39,0.95),rgba(3,7,18,0.98))] p-5 shadow-[0_24px_80px_rgba(2,132,199,0.12)] backdrop-blur-xl"
>
<div className="mb-4 flex items-start justify-between gap-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-cyan-300/80">
Match Setup
</p>
<h2
id="match-variant-heading"
className="mt-1 text-xl font-semibold text-white"
>
Choose your chess variant
</h2>
</div>
<span className="rounded-full border border-cyan-400/25 bg-cyan-500/10 px-3 py-1 text-[11px] font-medium uppercase tracking-[0.24em] text-cyan-100">
Web3 Ready
</span>
</div>

<div className="grid gap-3 sm:grid-cols-2">
{CHESS_VARIANTS.map((variant) => {
const isSelected = variant.id === selectedVariant;

return (
<button
key={variant.id}
type="button"
onClick={() => onSelect(variant.id)}
aria-pressed={isSelected}
className={cn(
"group relative overflow-hidden rounded-2xl border px-4 py-4 text-left transition-all duration-300",
"border-white/10 bg-white/[0.03] hover:-translate-y-0.5 hover:border-white/20",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300/70 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-950",
isSelected &&
"border-cyan-300/60 bg-white/[0.07] shadow-[0_0_0_1px_rgba(103,232,249,0.25),0_20px_50px_rgba(34,211,238,0.15)]",
)}
>
<div
className={cn(
"pointer-events-none absolute inset-0 transition-opacity duration-300",
`bg-gradient-to-br ${variant.accent}`,
isSelected ? "opacity-20" : "opacity-0 group-hover:opacity-15",
)}
/>

<div className="relative flex items-start justify-between gap-4">
<div>
<div className="flex items-center gap-2">
<h3 className="text-base font-semibold text-white">
{variant.label}
</h3>
<span
className={cn(
"rounded-full border px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.24em]",
variant.badgeClassName,
)}
>
{variant.averageGameTime}
</span>
</div>
<p className="mt-1 text-sm font-medium text-cyan-100/85">
{variant.subtitle}
</p>
<p className="mt-2 text-sm leading-6 text-slate-300">
{variant.description}
</p>
</div>

<span
className={cn(
"mt-1 flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full border transition-all duration-300",
isSelected
? "border-cyan-300 bg-cyan-300/15"
: "border-white/15 bg-white/5",
!isSelected && variant.ringClassName,
)}
aria-hidden="true"
>
<span
className={cn(
"h-2.5 w-2.5 rounded-full transition-all duration-300",
isSelected ? "bg-cyan-200" : "bg-transparent",
)}
/>
</span>
</div>
</button>
);
})}
</div>
</section>
);
}
36 changes: 36 additions & 0 deletions frontend/components/__tests__/ChessVariantSelector.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import React from "react";
import { fireEvent, render, screen } from "@testing-library/react";
import { ChessVariantSelector } from "@/components/ChessVariantSelector";

describe("ChessVariantSelector", () => {
it("renders all configured variants and marks the active one", () => {
render(
<ChessVariantSelector
selectedVariant="standard"
onSelect={() => undefined}
/>,
);

expect(
screen.getByRole("button", { name: /^standard\b/i }),
).toHaveAttribute("aria-pressed", "true");
expect(
screen.getByRole("button", { name: /^rapid\b/i }),
).toHaveAttribute("aria-pressed", "false");
expect(screen.getByText("Blitz")).toBeInTheDocument();
expect(screen.getByText("Bullet")).toBeInTheDocument();
});

it("notifies consumers when the user picks another variant", () => {
const onSelect = vi.fn();

render(
<ChessVariantSelector selectedVariant="standard" onSelect={onSelect} />,
);

fireEvent.click(screen.getByRole("button", { name: /^bullet\b/i }));

expect(onSelect).toHaveBeenCalledTimes(1);
expect(onSelect).toHaveBeenCalledWith("bullet");
});
});
21 changes: 19 additions & 2 deletions frontend/context/matchmakingContext.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,37 @@
"use client";
import React, { createContext, useContext, useState } from "react";
import React, { createContext, useContext, useMemo, useState } from "react";
import {
DEFAULT_CHESS_VARIANT,
type ChessVariant,
} from "@/lib/chessVariants";

export type AiPersonality = "aggressive" | "defensive" | "sacrificial";

type MatchmakingContextType = {
aiPersonality: AiPersonality;
setAiPersonality: (personality: AiPersonality) => void;
chessVariant: ChessVariant;
setChessVariant: (variant: ChessVariant) => void;
};

const MatchmakingContext = createContext<MatchmakingContextType | undefined>(undefined);

export function MatchmakingProvider({ children }: { children: React.ReactNode }) {
const [aiPersonality, setAiPersonality] = useState<AiPersonality>("aggressive");
const [chessVariant, setChessVariant] =
useState<ChessVariant>(DEFAULT_CHESS_VARIANT);
const value = useMemo(
() => ({
aiPersonality,
setAiPersonality,
chessVariant,
setChessVariant,
}),
[aiPersonality, chessVariant],
);

return (
<MatchmakingContext.Provider value={{ aiPersonality, setAiPersonality }}>
<MatchmakingContext.Provider value={value}>
{children}
</MatchmakingContext.Provider>
);
Expand Down
70 changes: 70 additions & 0 deletions frontend/lib/chessVariants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
export type ChessVariant = "standard" | "rapid" | "blitz" | "bullet";

export interface ChessVariantOption {
id: ChessVariant;
label: string;
subtitle: string;
description: string;
averageGameTime: string;
accent: string;
badgeClassName: string;
ringClassName: string;
}

/**
* Preset match formats shared by the home screen and matchmaking modal.
* Declaring them once keeps the UI cheap to render and easy to test.
*/
export const CHESS_VARIANTS: readonly ChessVariantOption[] = [
{
id: "standard",
label: "Standard",
subtitle: "Balanced queue",
description: "Classic pacing for thoughtful play and smooth Web3 setup.",
averageGameTime: "10-15 min",
accent: "from-sky-500 to-cyan-400",
badgeClassName: "bg-sky-500/15 text-sky-200 border-sky-400/30",
ringClassName: "group-hover:border-sky-400/60 group-hover:bg-sky-500/10",
},
{
id: "rapid",
label: "Rapid",
subtitle: "Measured pressure",
description: "Faster than standard while still leaving room for strategy.",
averageGameTime: "8-10 min",
accent: "from-emerald-500 to-teal-400",
badgeClassName: "bg-emerald-500/15 text-emerald-200 border-emerald-400/30",
ringClassName:
"group-hover:border-emerald-400/60 group-hover:bg-emerald-500/10",
},
{
id: "blitz",
label: "Blitz",
subtitle: "Fast tactical swings",
description: "High-tempo matches built for sharp decisions and quick turns.",
averageGameTime: "3-5 min",
accent: "from-amber-500 to-orange-400",
badgeClassName: "bg-amber-500/15 text-amber-200 border-amber-400/30",
ringClassName:
"group-hover:border-amber-400/60 group-hover:bg-amber-500/10",
},
{
id: "bullet",
label: "Bullet",
subtitle: "Pure adrenaline",
description: "Ultra-fast games for players who want nonstop momentum.",
averageGameTime: "< 2 min",
accent: "from-fuchsia-500 to-pink-500",
badgeClassName: "bg-fuchsia-500/15 text-fuchsia-200 border-fuchsia-400/30",
ringClassName:
"group-hover:border-fuchsia-400/60 group-hover:bg-fuchsia-500/10",
},
] as const;

export const DEFAULT_CHESS_VARIANT: ChessVariant = "standard";

export function getChessVariantById(variant: ChessVariant): ChessVariantOption {
return (
CHESS_VARIANTS.find((option) => option.id === variant) ?? CHESS_VARIANTS[0]
);
}
Loading
Loading