branch:
app.tsx
15117 bytesRaw
import { useEffect, useRef, useState } from "react";
import { useAgent } from "agents/react";
import { createRoot } from "react-dom/client";
import { Chess, type Square } from "chess.js";
import {
  Chessboard,
  type ChessboardOptions,
  type PieceDropHandlerArgs
} from "react-chessboard";

import type { State as ServerState } from "./chess";

function usePlayerId() {
  const [pid] = useState(() => {
    const existing = localStorage.getItem("playerId");
    if (existing) return existing;
    const id = crypto.randomUUID();
    localStorage.setItem("playerId", id);
    return id;
  });
  return pid;
}

type JoinReply = {
  ok: true;
  role: "w" | "b" | "spectator";
  state: ServerState;
};

function describeGameStatus(state: ServerState | null): string {
  if (!state) return "Connecting to game...";
  switch (state.status) {
    case "waiting":
      return "Waiting for players";
    case "active":
      return "In progress";
    case "mate":
      return state.winner === "w"
        ? "Checkmate · White wins"
        : "Checkmate · Black wins";
    case "draw":
      return "Draw";
    case "resigned":
      return state.winner
        ? `${state.winner === "w" ? "White" : "Black"} wins by resignation`
        : "Game ended by resignation";
    default:
      return state.status;
  }
}

type PlayerSlotProps = {
  label: string;
  playerId?: string;
  isCurrent: boolean;
};

function PlayerSlot({ label, playerId, isCurrent }: PlayerSlotProps) {
  const connected = Boolean(playerId);
  const highlight = isCurrent
    ? "rgba(37, 99, 235, 0.12)"
    : connected
      ? "rgba(59, 130, 246, 0.1)"
      : "rgba(15, 23, 42, 0.04)";
  const border = isCurrent
    ? "1px solid rgba(37, 99, 235, 0.6)"
    : "1px solid rgba(15, 23, 42, 0.12)";

  return (
    <div
      style={{
        width: "100%",
        borderRadius: "12px",
        padding: "12px 14px",
        backgroundColor: highlight,
        border
      }}
    >
      <div style={{ fontWeight: 600, marginBottom: "4px" }}>{label}</div>
      <div style={{ fontSize: "0.85rem", color: "#475569" }}>
        {connected ? (
          <>
            {isCurrent ? "You" : "Player"} · <code>{playerId!.slice(-8)}</code>
          </>
        ) : (
          "Waiting for player"
        )}
      </div>
    </div>
  );
}

/** --------------------------
 *  Main App
 *  -------------------------- */
function App() {
  const widgetGameId =
    typeof window.openai?.widgetState?.gameId === "string" &&
    `${window.openai?.widgetState?.gameId === "string"}`.trim()
      ? `${window.openai?.widgetState?.gameId}`.trim()
      : "";
  const playerId = usePlayerId();

  const [gameId, setGameId] = useState<string | null>(
    widgetGameId ? widgetGameId : null
  );
  const [gameIdInput, setGameIdInput] = useState(widgetGameId);
  const [menuError, setMenuError] = useState<string | null>(null);

  const gameRef = useRef(new Chess());
  const [fen, setFen] = useState(gameRef.current.fen());
  const [myColor, setMyColor] = useState<"w" | "b" | "spectator">("spectator");
  const [pending, setPending] = useState(false);
  const [serverState, setServerState] = useState<ServerState | null>(null);
  const [joined, setJoined] = useState(false);

  useEffect(() => {
    if (!gameId && widgetGameId) {
      setGameId(widgetGameId);
    }
    if (!gameIdInput && widgetGameId) {
      setGameIdInput(widgetGameId);
    }
  }, [widgetGameId, gameId, gameIdInput]);

  function resetLocalGame() {
    gameRef.current.reset();
    setFen(gameRef.current.fen());
    setPending(false);
    setMyColor("spectator");
    setServerState(null);
  }

  const activeGameName = gameId ?? "__lobby__";
  const host = window.HOST ?? "http://localhost:5174/";

  const { stub } = useAgent<ServerState>({
    host,
    name: activeGameName,
    agent: "chess",
    onStateUpdate: (s) => {
      if (!gameId) return;
      gameRef.current.load(s.board);
      setFen(s.board);
      setServerState(s);
    }
  });

  useEffect(() => {
    if (!gameId || joined) return;

    (async () => {
      try {
        const res = (await stub.join({
          playerId,
          preferred: "any"
        })) as JoinReply;

        if (!res?.ok) return;

        setMyColor(res.role);
        gameRef.current.load(res.state.board);
        setFen(res.state.board);
        setServerState(res.state);
        setJoined(true);
      } catch (error) {
        console.error("Failed to join game", error);
      }
    })();
  }, [playerId, gameId, stub, joined]);

  async function handleStartNewGame() {
    const newId = crypto.randomUUID();
    await window.openai?.setWidgetState({ gameId: newId });
    resetLocalGame();
    setMenuError(null);
    setGameIdInput(newId);
    setGameId(newId);
  }

  async function handleJoinGame() {
    const trimmed = gameIdInput.trim();
    if (!trimmed) {
      setMenuError("Enter a game ID to join.");
      return;
    }
    resetLocalGame();
    setMenuError(null);
    await window.openai?.setWidgetState({ gameId: trimmed });
    setGameId(trimmed);
  }

  // Trigger a message on the ChatGPT conversation to help with the current board state
  const handleHelpClick = () => {
    window.openai?.sendFollowUpMessage?.({
      prompt: `Help me with my chess game. I am playing as ${myColor} and the board is: ${fen}. Please only offer written advice as there are no tools for you to use.`
    });
  };

  const handleResign = async () => {
    await stub.resign();
  };

  // Local-then-server move with reconcile
  function onPieceDrop({
    sourceSquare,
    targetSquare
  }: PieceDropHandlerArgs): boolean {
    if (!gameId || !sourceSquare || !targetSquare || pending) return false;

    const game = gameRef.current;

    // must be seated and your turn
    if (myColor === "spectator") return false;
    if (game.turn() !== myColor) return false;

    // must be your piece
    const piece = game.get(sourceSquare as Square);
    if (!piece || piece.color !== myColor) return false;

    const prevFen = game.fen();

    try {
      const local = game.move({
        from: sourceSquare,
        to: targetSquare,
        promotion: "q"
      });
      if (!local) return false;
    } catch {
      return false;
    }

    const nextFen = game.fen();
    setFen(nextFen);
    setPending(true);

    // reconcile with server
    stub
      .move({ from: sourceSquare, to: targetSquare, promotion: "q" }, prevFen)
      .then((r: { ok: boolean; fen: string }) => {
        if (!r.ok) {
          // rollback to server position
          game.load(r.fen);
          setFen(r.fen);
        }
      })
      .finally(() => setPending(false));

    return true;
  }

  const chessboardOptions: ChessboardOptions = {
    id: `pvp-${activeGameName}`,
    position: fen,
    onPieceDrop,
    boardOrientation: myColor === "b" ? "black" : "white",
    allowDragging: !pending && myColor !== "spectator"
  };

  const maxSize = window.openai?.maxHeight ?? 650;
  const boardSize = Math.max(Math.min(maxSize - 120, 560), 320);
  const statusText = describeGameStatus(serverState);
  const whiteId = serverState?.players?.w;
  const blackId = serverState?.players?.b;

  return (
    <div
      style={{
        padding: "20px 16px",
        background: "linear-gradient(180deg, #f8fafc 0%, #e2e8f0 100%)",
        minHeight: "100%",
        boxSizing: "border-box"
      }}
    >
      <div style={{ maxWidth: "960px", margin: "0 auto" }}>
        {!gameId ? (
          <div
            style={{
              maxWidth: "420px",
              margin: "0 auto",
              backgroundColor: "#ffffff",
              borderRadius: "16px",
              padding: "24px",
              boxShadow: "0 12px 30px rgba(15, 23, 42, 0.12)",
              display: "flex",
              flexDirection: "column",
              gap: "16px"
            }}
          >
            <h1 style={{ fontSize: "1.5rem", margin: 0 }}>Ready to play?</h1>
            <p style={{ margin: 0, color: "#475569", lineHeight: 1.4 }}>
              Start a new match to generate a shareable game code or join an
              existing game by pasting its ID below.
            </p>
            <button
              type="button"
              style={{
                padding: "12px 16px",
                borderRadius: "12px",
                border: "none",
                background: "#2563eb",
                color: "#ffffff",
                fontWeight: 600,
                cursor: "pointer"
              }}
              onClick={handleStartNewGame}
            >
              Start a new game
            </button>
            <div
              style={{ display: "flex", flexDirection: "column", gap: "8px" }}
            >
              <label
                htmlFor="gameId"
                style={{ fontSize: "0.85rem", color: "#475569" }}
              >
                Join with game ID
              </label>
              <div style={{ display: "flex", gap: "8px" }}>
                <input
                  name="gameId"
                  style={{
                    flex: 1,
                    padding: "10px 12px",
                    borderRadius: "10px",
                    border: "1px solid rgba(15, 23, 42, 0.2)",
                    fontSize: "0.95rem"
                  }}
                  placeholder="Paste a game ID"
                  value={gameIdInput}
                  onChange={(event) => {
                    setGameIdInput(event.target.value);
                    if (menuError) setMenuError(null);
                  }}
                />
                <button
                  type="button"
                  style={{
                    padding: "10px 16px",
                    borderRadius: "10px",
                    border: "none",
                    background: "#0f172a",
                    color: "#ffffff",
                    fontWeight: 600,
                    cursor: "pointer"
                  }}
                  onClick={handleJoinGame}
                >
                  Join
                </button>
              </div>
              {menuError ? (
                <span style={{ color: "#b91c1c", fontSize: "0.8rem" }}>
                  {menuError}
                </span>
              ) : null}
            </div>
          </div>
        ) : (
          <div
            style={{
              display: "flex",
              flexDirection: "column",
              gap: "16px"
            }}
          >
            <div
              style={{
                backgroundColor: "#ffffff",
                padding: "18px 20px",
                borderRadius: "16px",
                boxShadow: "0 10px 24px rgba(15, 23, 42, 0.12)",
                display: "flex",
                flexWrap: "wrap",
                gap: "16px",
                justifyContent: "space-between",
                alignItems: "center"
              }}
            >
              <div>
                <div style={{ fontSize: "1.1rem", fontWeight: 600 }}>
                  Game {gameId}
                </div>
                <div style={{ fontSize: "0.9rem", color: "#475569" }}>
                  {statusText}
                </div>
                <div style={{ fontSize: "0.9rem", color: "#475569" }}>
                  {myColor === "spectator"
                    ? "You are watching as a spectator"
                    : playerId === serverState?.players[gameRef.current.turn()]
                      ? "Your turn"
                      : `Waiting for ${myColor === "w" ? "Black" : "White"}`}
                </div>
              </div>
              <div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
                <button
                  type="button"
                  style={{
                    padding: "10px 16px",
                    borderRadius: "10px",
                    border: "none",
                    background: "red",
                    color: "#ffffff",
                    fontWeight: 600,
                    cursor: "pointer"
                  }}
                  onClick={handleResign}
                >
                  Resign
                </button>
                <button
                  type="button"
                  style={{
                    padding: "10px 16px",
                    borderRadius: "10px",
                    border: "none",
                    background: "#2563eb",
                    color: "#ffffff",
                    fontWeight: 600,
                    cursor: "pointer"
                  }}
                  onClick={handleHelpClick}
                >
                  Ask for help
                </button>
              </div>
            </div>
            <div
              style={{
                backgroundColor: "#ffffff",
                borderRadius: "16px",
                padding: "16px",
                boxShadow: "0 10px 24px rgba(15, 23, 42, 0.1)",
                display: "flex",
                flexDirection: "column",
                gap: "12px"
              }}
            >
              <div style={{ fontWeight: 600, fontSize: "1rem" }}>Players</div>
              <div
                style={{
                  display: "flex",
                  gap: "12px"
                }}
              >
                <PlayerSlot
                  label="White"
                  playerId={whiteId}
                  isCurrent={whiteId === playerId}
                />
                <PlayerSlot
                  label="Black"
                  playerId={blackId}
                  isCurrent={blackId === playerId}
                />
              </div>
              {myColor === "spectator" ? (
                <div style={{ fontSize: "0.85rem", color: "#475569" }}>
                  You're observing for now. We'll seat you automatically if a
                  spot opens up.
                </div>
              ) : null}
            </div>

            <div
              style={{
                display: "flex",
                flexWrap: "wrap",
                gap: "16px",
                alignItems: "flex-start",
                justifyContent: "center"
              }}
            >
              <div
                style={{
                  flex: "1 1 360px",
                  display: "flex",
                  justifyContent: "center",
                  backgroundColor: "#ffffff",
                  borderRadius: "16px",
                  padding: "16px",
                  boxShadow: "0 10px 24px rgba(15, 23, 42, 0.1)"
                }}
              >
                <div
                  style={{
                    width: `${boardSize}px`,
                    height: `${boardSize}px`
                  }}
                >
                  <Chessboard
                    options={{
                      ...chessboardOptions,
                      id: `pvp-${gameId}-${myColor}`
                    }}
                  />
                </div>
              </div>
            </div>
          </div>
        )}
      </div>
    </div>
  );
}

const root = createRoot(document.getElementById("root")!);
root.render(<App />);