diff --git a/.env.example b/.env.example index 52a7624..39526e3 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,12 @@ POSTGRES_DB=chessrepeat FIREBASE_PROJECT_ID= ALLOWED_ORIGINS=https://example.com +# opening explorer (internal-only service, same compose project). The +# backend reaches it by service name over the compose network. For local +# dev without compose, point at the published loopback port instead: +# EXPLORER_URL=http://localhost:8090 +EXPLORER_URL=http://explorer:8080 + # backups DB_BACKUP_TAG= TZ=UTC diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e6aee2c..f1b9b58 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -9,6 +9,7 @@ on: env: REGISTRY: ghcr.io BACKEND_IMAGE: ${{ github.repository }}-backend + EXPLORER_IMAGE: ${{ github.repository }}-explorer jobs: # run tests before deploy. @@ -27,6 +28,11 @@ jobs: - name: test working-directory: backend run: go test ./... + - name: explorer vet + test + working-directory: explorer + run: | + go vet ./... + go test ./... build-and-push: if: github.event_name == 'push' @@ -53,6 +59,16 @@ jobs: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE }}:${{ github.sha }} cache-from: type=gha,scope=backend cache-to: type=gha,scope=backend,mode=max + - name: build & push explorer + uses: docker/build-push-action@v6 + with: + context: explorer + push: true + tags: | + ${{ env.REGISTRY }}/${{ env.EXPLORER_IMAGE }}:latest + ${{ env.REGISTRY }}/${{ env.EXPLORER_IMAGE }}:${{ github.sha }} + cache-from: type=gha,scope=explorer + cache-to: type=gha,scope=explorer,mode=max deploy: if: github.event_name == 'push' diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index f7d683e..08e73b8 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -29,6 +29,9 @@ func main() { // Chapter creation is an HTTP POST (the tree exceeds the WS frame // cap); on success the ws server nudges the owner's room to resync. api.RegisterChapterRoute(httpMux, db, wsServer) + // Opening-explorer proxy: browser -> here (cookie auth) -> explorer + // service (bearer token). Disabled if EXPLORER_URL/token are unset. + api.RegisterExplorerRoute(httpMux, db, cfg.ExplorerURL) // rootMux dispatches WS upgrades directly (long-lived, no timeout) // and everything else through the timeout wrapper. diff --git a/backend/internal/api/explorer.go b/backend/internal/api/explorer.go new file mode 100644 index 0000000..a3b07e8 --- /dev/null +++ b/backend/internal/api/explorer.go @@ -0,0 +1,68 @@ +package api + +import ( + "io" + "log" + "net/http" + "net/url" + "time" + + "chessrepeat/internal/auth" + "chessrepeat/internal/store" +) + +// explorerClient bounds the server-to-server call so a slow explorer +// can't tie up a request goroutine indefinitely. +var explorerClient = &http.Client{Timeout: 5 * time.Second} + +// GetExplorer proxies opening-explorer stats for a position. The browser +// hits this cookie-authenticated endpoint (same session gate as every +// other API route); the backend forwards to the explorer, an internal- +// only service reached over the compose network. Response body is the +// explorer's JSON passed through unchanged. +func GetExplorer(db store.Repo, explorerURL string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if _, ok := auth.RequireSession(db, w, r); !ok { + return + } + if explorerURL == "" { + // Feature disabled (e.g. local dev without the explorer running). + // Return an empty result rather than an error so the UI just + // shows "no games" and the browser console stays clean. + w.Header().Set("Content-Type", "application/json") + w.Write([]byte("null")) + return + } + + fen := r.URL.Query().Get("fen") + if fen == "" { + http.Error(w, "missing fen", http.StatusBadRequest) + return + } + + upstream := explorerURL + "/query?fen=" + url.QueryEscape(fen) + req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, upstream, nil) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + resp, err := explorerClient.Do(req) + if err != nil { + log.Println("explorer query failed:", err) + http.Error(w, "explorer unavailable", http.StatusBadGateway) + return + } + defer resp.Body.Close() + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(resp.StatusCode) + io.Copy(w, resp.Body) + } +} + +// RegisterExplorerRoute wires the /explorer proxy. No-op-friendly: when +// the explorer isn't configured the handler returns an empty result. +func RegisterExplorerRoute(mux *http.ServeMux, db store.Repo, explorerURL string) { + mux.HandleFunc("GET /explorer", GetExplorer(db, explorerURL)) +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 7286699..1341d3a 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -28,6 +28,11 @@ type Config struct { // chessrepeat.com and the API at api.chessrepeat.com). Leave unset // in local dev where SPA and API share the same host. HintCookieDomain string + // ExplorerURL is the base URL of the opening-explorer service, an + // internal-only container the backend reaches over the compose + // network. The browser never touches it, so no token is involved. + // Empty disables the /explorer route. + ExplorerURL string } // Load reads .env (if present) and resolves every config value, applying @@ -46,6 +51,7 @@ func Load() Config { FirebaseProjectID: strings.TrimSpace(os.Getenv("FIREBASE_PROJECT_ID")), CookieSecure: strings.EqualFold(os.Getenv("COOKIE_SECURE"), "true"), HintCookieDomain: strings.TrimSpace(os.Getenv("HINT_COOKIE_DOMAIN")), + ExplorerURL: strings.TrimRight(strings.TrimSpace(os.Getenv("EXPLORER_URL")), "/"), } if cfg.FirebaseProjectID == "" { diff --git a/docker-compose.yml b/docker-compose.yml index 9b90fbb..f04d99b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,14 +26,31 @@ services: ALLOWED_ORIGINS: ${ALLOWED_ORIGINS} LISTEN_ADDR: ":8080" COOKIE_SECURE: "true" - #chessrepeat.com, api.chessrepeat.com + #chessrepeat.com, api.chessrepeat.com HINT_COOKIE_DOMAIN: ${HINT_COOKIE_DOMAIN} + # Opening-explorer service on the compose network (internal-only, + # no token — it isn't reachable from the internet). + EXPLORER_URL: ${EXPLORER_URL:-http://explorer:8080} ports: - "127.0.0.1:8080:8080" depends_on: postgres: condition: service_healthy + # Opening explorer: pebble-backed position stats. Internal-only — no + # Caddy route. The host publish is loopback (for the import CLI on the + # box), not the public internet. + explorer: + image: ghcr.io/${GH_OWNER}/chessrepeat-explorer:${IMAGE_TAG:-latest} + restart: unless-stopped + environment: + DB_PATH: /data/chess + LISTEN_ADDR: ":8080" + ports: + - "127.0.0.1:8090:8080" + volumes: + - explorerdata:/data + # daily db backup sidecar to r2 or s3 db-backup: image: tiredofit/db-backup:${DB_BACKUP_TAG:-latest} @@ -76,3 +93,4 @@ services: volumes: pgdata: + explorerdata: diff --git a/explorer/.dockerignore b/explorer/.dockerignore new file mode 100644 index 0000000..38b842c --- /dev/null +++ b/explorer/.dockerignore @@ -0,0 +1,10 @@ +.env +.env.* +*.log +*.pgn +chess/ +explorer +README.md +DEPLOY.md +.github +query.http diff --git a/explorer/.gitignore b/explorer/.gitignore new file mode 100644 index 0000000..b7a8efb --- /dev/null +++ b/explorer/.gitignore @@ -0,0 +1,40 @@ +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Code coverage profiles and other test artifacts +*.out +coverage.* +*.coverprofile +profile.cov + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work +go.work.sum + +# env file +.env + +# Editor/IDE +# .idea/ +# .vscode/ + +*.pgn +chess/ + +# Local build outputs +/explorer +/chessrepeat-explorer +/import-pgn diff --git a/explorer/Dockerfile b/explorer/Dockerfile new file mode 100644 index 0000000..2067ed7 --- /dev/null +++ b/explorer/Dockerfile @@ -0,0 +1,29 @@ +# syntax=docker/dockerfile:1 + +# Build stage: compile a fully static binary so the runtime image can be +# distroless. CGO is off — pebble and its deps build pure-Go. +FROM golang:1.26-alpine AS build +WORKDIR /src + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" \ + -o /out/explorer ./ +# Empty data dir to ship in the runtime image (distroless has no shell to +# mkdir there). +RUN mkdir -p /out/data + +# Runtime stage: distroless static. No shell, no package manager, runs +# as nonroot. Pebble data lives on a mounted volume via DB_PATH. +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=build /out/explorer /explorer +# /data must exist and be owned by nonroot: a fresh named volume copies +# the image's ownership on first mount. Without this the volume is +# root-owned and pebble fails with "mkdir /data/chess: permission denied". +COPY --from=build --chown=nonroot:nonroot /out/data /data +ENV DB_PATH=/data/chess LISTEN_ADDR=:8080 +EXPOSE 8080 +USER nonroot:nonroot +ENTRYPOINT ["/explorer"] diff --git a/explorer/cmd/import-pgn/main.go b/explorer/cmd/import-pgn/main.go new file mode 100644 index 0000000..aeb97c6 --- /dev/null +++ b/explorer/cmd/import-pgn/main.go @@ -0,0 +1,124 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + + "github.com/corentings/chess" +) + +// Payload is the per-game body sent to the /import endpoint. +type Payload struct { + Winner chess.Color `json:"winner"` + Moves []string `json:"moves"` +} + +// winner maps a game outcome to the winning color, or NoColor for a draw/no result. +func winner(o chess.Outcome) chess.Color { + switch o { + case chess.WhiteWon: + return chess.White + case chess.BlackWon: + return chess.Black + default: + return chess.NoColor + } +} + +// batchSize is how many games go in each PUT. The server aggregates a +// batch in memory and commits it with a single fsync, so larger batches +// mean far fewer syncs; 1000 games is ~a few hundred KB of JSON. +const batchSize = 1000 + +// importResult mirrors the server's ImportResult. +type importResult struct { + Imported int `json:"imported"` + Skipped int `json:"skipped"` +} + +func main() { + if len(os.Args) < 3 { + fmt.Println("Usage: import-pgn ") + os.Exit(1) + } + + pgnPath := os.Args[1] + apiPath := os.Args[2] + + f, err := os.Open(pgnPath) + if err != nil { + panic(err) + } + defer f.Close() + + client := &http.Client{} + notation := chess.AlgebraicNotation{} + var total importResult + + send := func(batch []Payload) { + jsonData, err := json.Marshal(batch) + if err != nil { + fmt.Printf("Error marshaling payload: %v\n", err) + os.Exit(1) + } + req, err := http.NewRequest("PUT", apiPath, bytes.NewBuffer(jsonData)) + if err != nil { + fmt.Printf("Error creating request: %v\n", err) + os.Exit(1) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + fmt.Printf("Error sending request: %v\n", err) + os.Exit(1) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + fmt.Printf("Import failed: %s %s\n", resp.Status, body) + os.Exit(1) + } + var res importResult + if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { + fmt.Printf("Error decoding response: %v\n", err) + os.Exit(1) + } + total.Imported += res.Imported + total.Skipped += res.Skipped + fmt.Printf("imported %d games (%d skipped)\n", total.Imported, total.Skipped) + } + + batch := make([]Payload, 0, batchSize) + scanner := chess.NewScanner(f) + // for each game + for scanner.Scan() { + game := scanner.Next() + if game == nil { + continue + } + + // Encode each move in the game's notation (SAN), matching what the + // server decodes with game.MoveStr. + moves := game.Moves() + positions := game.Positions() + notated := make([]string, len(moves)) + for i, m := range moves { + notated[i] = notation.Encode(positions[i], m) + } + + // can also add filtering step here... + batch = append(batch, Payload{Winner: winner(game.Outcome()), Moves: notated}) + if len(batch) == batchSize { + send(batch) + batch = batch[:0] + } + } + if len(batch) > 0 { + send(batch) + } +} diff --git a/explorer/go.mod b/explorer/go.mod new file mode 100644 index 0000000..86864eb --- /dev/null +++ b/explorer/go.mod @@ -0,0 +1,35 @@ +module github.com/jacokyle01/chessrepeat-explorer + +go 1.26.0 + +require ( + github.com/DataDog/zstd v1.4.5 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/cockroachdb/errors v1.11.3 // indirect + github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce // indirect + github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b // indirect + github.com/cockroachdb/pebble v1.1.5 // indirect + github.com/cockroachdb/redact v1.1.5 // indirect + github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 // indirect + github.com/corentings/chess v0.0.0-20241113092200-3f44c4e3e959 // indirect + github.com/freeeve/pgn/v3 v3.2.0 // indirect + github.com/getsentry/sentry-go v0.27.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.3 // indirect + github.com/golang/snappy v0.0.4 // indirect + github.com/klauspost/compress v1.18.2 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/prometheus/client_golang v1.15.0 // indirect + github.com/prometheus/client_model v0.3.0 // indirect + github.com/prometheus/common v0.42.0 // indirect + github.com/prometheus/procfs v0.9.0 // indirect + github.com/rogpeppe/go-internal v1.9.0 // indirect + golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df // indirect + golang.org/x/sys v0.18.0 // indirect + golang.org/x/text v0.14.0 // indirect + google.golang.org/protobuf v1.33.0 // indirect +) diff --git a/explorer/go.sum b/explorer/go.sum new file mode 100644 index 0000000..218663d --- /dev/null +++ b/explorer/go.sum @@ -0,0 +1,98 @@ +github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ= +github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cockroachdb/errors v1.11.3 h1:5bA+k2Y6r+oz/6Z/RFlNeVCesGARKuC6YymtcDrbC/I= +github.com/cockroachdb/errors v1.11.3/go.mod h1:m4UIW4CDjx+R5cybPsNrRbreomiFqt8o1h1wUVazSd8= +github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce h1:giXvy4KSc/6g/esnpM7Geqxka4WSqI1SZc7sMJFd3y4= +github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce/go.mod h1:9/y3cnZ5GKakj/H4y9r9GTjCvAFta7KLgSHPJJYc52M= +github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b h1:r6VH0faHjZeQy818SGhaone5OnYfxFR/+AzdY3sf5aE= +github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs= +github.com/cockroachdb/pebble v1.1.5 h1:5AAWCBWbat0uE0blr8qzufZP5tBjkRyy/jWe1QWLnvw= +github.com/cockroachdb/pebble v1.1.5/go.mod h1:17wO9el1YEigxkP/YtV8NtCivQDgoCyBg5c4VR/eOWo= +github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwPJ30= +github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg= +github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 h1:zuQyyAKVxetITBuuhv3BI9cMrmStnpT18zmgmTxunpo= +github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ= +github.com/corentings/chess v0.0.0-20241113092200-3f44c4e3e959 h1:B5Fq6d2MB3li9PXDkm3hR7iMOCgJS6m+igsjtGe2iHA= +github.com/corentings/chess v0.0.0-20241113092200-3f44c4e3e959/go.mod h1:JBpJId2nojl6rPpbdVJn7FZOATH0Z28vaU+BEp9ttKA= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/freeeve/pgn/v3 v3.2.0 h1:7Jw9LP2wdd9Kty7TwzTp605mf1ipd8jhR9sHZH1Ppcs= +github.com/freeeve/pgn/v3 v3.2.0/go.mod h1:59s7u2pLPSaGT+Nwbr2YWPetSD2IDQ3MsOrTOa1V0Q0= +github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps= +github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.16.0 h1:iULayQNOReoYUe+1qtKOqw9CwJv3aNQu8ivo7lw1HU4= +github.com/klauspost/compress v1.16.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= +github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= +github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/prometheus/client_golang v1.15.0 h1:5fCgGYogn0hFdhyhLbw7hEsWxufKtY9klyvdNfFlFhM= +github.com/prometheus/client_golang v1.15.0/go.mod h1:e9yaBhRPU2pPNsZwE+JdQl0KEt1N9XgF6zxWmaC0xOk= +github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4= +github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w= +github.com/prometheus/common v0.42.0 h1:EKsfXEYo4JpWMHH5cg+KOUWeuJSov1Id8zGR8eeI1YM= +github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc= +github.com/prometheus/procfs v0.9.0 h1:wzCHvIvM5SxWqYvwgVL7yJY8Lz3PKn49KQtpgMYJfhI= +github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df h1:UA2aFVmmsIlefxMk29Dp2juaUSth8Pyn3Tq5Y5mJGME= +golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= diff --git a/explorer/main.go b/explorer/main.go new file mode 100644 index 0000000..5659994 --- /dev/null +++ b/explorer/main.go @@ -0,0 +1,296 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "os" + "strings" + + "github.com/cockroachdb/pebble" + "github.com/corentings/chess" +) + +// positionKey is the pebble key for a position: piece placement and side +// to move only. It deliberately drops castling rights, the en passant +// square and the move counters from the FEN. +// +// Imports build positions move-by-move with corentings, while queries +// arrive as FENs built by the frontend (chessops), and the two disagree +// on everything past side to move: +// - ep: corentings always sets it after a double push; chessops only +// when a capture is legal. +// - castling: the frontend's move tree can carry "-" where the import +// has "KQkq" (nodes built from a board-only root FEN). +// +// Keying on the fields both sides agree on makes lookups match, and also +// lets transpositions aggregate, which is what an opening explorer wants. +func positionKey(pos *chess.Position) string { + fields := strings.Fields(pos.String()) + if len(fields) >= 2 { + return strings.Join(fields[:2], " ") + } + return pos.String() +} + +func main() { + // DB_PATH lets deployments point pebble at a mounted volume; defaults + // to a relative "chess" dir for local runs. + dbPath := os.Getenv("DB_PATH") + if dbPath == "" { + dbPath = "chess" + } + // LISTEN_ADDR keeps the bind address configurable across environments. + listenAddr := os.Getenv("LISTEN_ADDR") + if listenAddr == "" { + listenAddr = ":8090" + } + + options := &pebble.Options{ + Merger: &pebble.Merger{ + Name: "chess.stats.merger", + Merge: func(key, value []byte) (pebble.ValueMerger, error) { + // pebble reuses value's buffer after Merge returns, so the + // merger must own a copy or merged stats get corrupted. + return &statsMerger{current: bytes.Clone(value)}, nil + }, + }, + } + db, err := pebble.Open(dbPath, options) + if err != nil { + log.Fatal(err) + } + defer db.Close() + + mux := http.NewServeMux() + // The explorer is an internal-only service (never routed from the + // public internet), so there's no auth: reachability is the boundary. + // Only the chessrepeat backend and the on-box import CLI can connect. + mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + fmt.Fprintln(w, "ok") + }) + mux.HandleFunc("PUT /import", importHandler(db)) + mux.HandleFunc("GET /query", queryHandler(db)) + + log.Printf("listening on %s", listenAddr) + log.Fatal(http.ListenAndServe(listenAddr, mux)) +} + +type Payload struct { + Winner chess.Color `json:"winner"` + Moves []string `json:"moves"` +} + +type PebbleKey struct { + PositionHash string `json:"positionHash"` +} + +// opening explorer will display this for each move played at some position type Result +type PebbleValue struct { + Results []ResultSummary `json:"results"` +} + +type ResultSummary struct { + San string `json:"san"` + WhiteWins int `json:"whiteWins"` + BlackWins int `json:"blackWins"` + Draws int `json:"draw"` +} + +// ImportResult reports how many games of a batch were indexed. Games with +// an illegal or unparseable move are skipped whole rather than failing the +// batch, so one bad game in a large PGN doesn't drop its neighbours. +type ImportResult struct { + Imported int `json:"imported"` + Skipped int `json:"skipped"` +} + +func importHandler(db *pebble.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var p []Payload + if err := json.NewDecoder(r.Body).Decode(&p); err != nil { + http.Error(w, `{"error": "Invalid request payload"}`, http.StatusBadRequest) + return + } + + // Aggregate the whole batch in memory first: position key -> san -> + // counts. Every game passes through the same opening positions, so + // this collapses thousands of per-move merges into one per position. + agg := map[string]map[string]*ResultSummary{} + var result ImportResult + for _, rawGame := range p { + // Validate the game fully before touching agg, so a bad move + // late in a game doesn't leave its earlier moves counted. + keys, ok := gameKeys(rawGame.Moves) + if !ok { + result.Skipped++ + continue + } + result.Imported++ + for i, move := range rawGame.Moves { + moves := agg[keys[i]] + if moves == nil { + moves = map[string]*ResultSummary{} + agg[keys[i]] = moves + } + summary := moves[move] + if summary == nil { + summary = &ResultSummary{San: move} + moves[move] = summary + } + switch rawGame.Winner { + case chess.White: + summary.WhiteWins++ + case chess.Black: + summary.BlackWins++ + default: + summary.Draws++ + } + } + } + + // One pebble batch and one fsync for the whole request. + batch := db.NewBatch() + defer batch.Close() + for key, moves := range agg { + value := PebbleValue{Results: make([]ResultSummary, 0, len(moves))} + for _, summary := range moves { + value.Results = append(value.Results, *summary) + } + valBytes, err := json.Marshal(value) + if err != nil { + http.Error(w, `{"error": "Failed to encode stats"}`, http.StatusInternalServerError) + return + } + // Merge so repeated positions accumulate via statsMerger. + if err := batch.Merge([]byte(key), valBytes, nil); err != nil { + http.Error(w, `{"error": "Failed to store position"}`, http.StatusInternalServerError) + return + } + } + if err := batch.Commit(pebble.Sync); err != nil { + http.Error(w, `{"error": "Failed to store position"}`, http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(result) + } +} + +// gameKeys replays a game and returns, for each move, the key of the +// position the move is played FROM (so the explorer can map a position to +// the moves played from it). ok is false if any move is illegal. +// +// Replays on a bare Position rather than a chess.Game: Game checks for +// repetitions after every move by stringifying every earlier position, +// which is quadratic in game length and dominated import time. +func gameKeys(moves []string) (keys []string, ok bool) { + pos := chess.StartingPosition() + keys = make([]string, len(moves)) + for i, san := range moves { + keys[i] = positionKey(pos) + // Decode only matches legal moves in pos. + m, err := chess.AlgebraicNotation{}.Decode(pos, san) + if err != nil { + return nil, false + } + pos = pos.Update(m) + } + return keys, true +} + +func queryHandler(db *pebble.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // 1. (Optional) Enforce the correct HTTP method + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // 2. Read the FEN from the query string (e.g. /query?fen=...) + fen := r.URL.Query().Get("fen") + if fen == "" { + http.Error(w, "Missing 'fen' query parameter", http.StatusBadRequest) + return + } + + var pos chess.Position + if err := pos.UnmarshalText([]byte(fen)); err != nil { + http.Error(w, "Invalid FEN", http.StatusBadRequest) + return + } + stats, err := GetPositionStats(db, PebbleKey{PositionHash: positionKey(&pos)}) + if err != nil { + http.Error(w, "Error /w DB", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + // 4. Encode and send the response + if err := json.NewEncoder(w).Encode(stats); err != nil { + // If encoding fails, handle the error (optional since headers are already sent) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } +} + +// statsMerger implements pebble.ValueMerger +type statsMerger struct { + current []byte +} + +func (m *statsMerger) MergeNewer(value []byte) error { + updated, err := mergeResultSummaries(m.current, value) + if err != nil { + return err + } + m.current = updated + return nil +} + +// MergeOlder is identical to MergeNewer: summing per-move counts is +// order-independent, so operand ordering does not matter. +func (m *statsMerger) MergeOlder(value []byte) error { + updated, err := mergeResultSummaries(m.current, value) + if err != nil { + return err + } + m.current = updated + return nil +} + +func (m *statsMerger) Finish(bool) ([]byte, io.Closer, error) { + return m.current, nil, nil +} + +// look up stats for a position +func GetPositionStats(db *pebble.DB, key PebbleKey) (*PebbleValue, error) { + valBytes, closer, err := db.Get([]byte(key.PositionHash)) + if err != nil { + if errors.Is(err, pebble.ErrNotFound) { + return nil, nil // Return nil if the position has no games played yet + } + return nil, fmt.Errorf("failed to get key from pebble: %w", err) + } + + defer func() { + if err := closer.Close(); err != nil { + } + }() + + // 3. Unmarshal the JSON bytes back into the PebbleValue struct + var stats PebbleValue + if err := json.Unmarshal(valBytes, &stats); err != nil { + return nil, fmt.Errorf("failed to unmarshal value: %w", err) + } + + return &stats, nil +} diff --git a/explorer/main_test.go b/explorer/main_test.go new file mode 100644 index 0000000..0cb84f1 --- /dev/null +++ b/explorer/main_test.go @@ -0,0 +1,49 @@ +package main + +import ( + "testing" + + "github.com/corentings/chess" +) + +// Imports key positions built move-by-move (corentings always sets the ep +// square after a double push); queries arrive as client FENs from chessops, +// which only include ep when a capture is legal and may carry "-" castling. +// All must map to the same key. +func TestPositionKeyMatchesFrontendFens(t *testing.T) { + game := chess.NewGame() + for _, m := range []string{"e4", "d5", "e5", "f5"} { + if err := game.MoveStr(m); err != nil { + t.Fatal(err) + } + } + imported := positionKey(game.Position()) + + for _, fen := range []string{ + "rnbqkbnr/ppp1p1pp/8/3pPp2/8/8/PPPP1PPP/RNBQKBNR w KQkq f6 0 3", // ep capture legal + "rnbqkbnr/ppp1p1pp/8/3pPp2/8/8/PPPP1PPP/RNBQKBNR w KQkq - 0 3", + "rnbqkbnr/ppp1p1pp/8/3pPp2/8/8/PPPP1PPP/RNBQKBNR w - f6 0 3", // frontend node with lost castling + "rnbqkbnr/ppp1p1pp/8/3pPp2/8/8/PPPP1PPP/RNBQKBNR w - - 0 3", + } { + var pos chess.Position + if err := pos.UnmarshalText([]byte(fen)); err != nil { + t.Fatalf("parse %q: %v", fen, err) + } + if got := positionKey(&pos); got != imported { + t.Errorf("key for %q = %q, want %q", fen, got, imported) + } + } + + // After 1. c3, as the frontend sends it (no castling rights). + game = chess.NewGame() + if err := game.MoveStr("c3"); err != nil { + t.Fatal(err) + } + var pos chess.Position + if err := pos.UnmarshalText([]byte("rnbqkbnr/pppppppp/8/8/8/2P5/PP1PPPPP/RNBQKBNR b - - 0 1")); err != nil { + t.Fatal(err) + } + if got, want := positionKey(&pos), positionKey(game.Position()); got != want { + t.Errorf("key after 1. c3 = %q, want %q", got, want) + } +} diff --git a/explorer/merge.go b/explorer/merge.go new file mode 100644 index 0000000..e9a176e --- /dev/null +++ b/explorer/merge.go @@ -0,0 +1,54 @@ +package main + +import ( + "bytes" + "encoding/json" +) + +// TODO +// mergeResultSummaries combines the old stats with the new stats +func mergeResultSummaries(oldBuf, newBuf []byte) ([]byte, error) { + var oldVal, newVal PebbleValue + + // Unmarshal new changes + if err := json.Unmarshal(newBuf, &newVal); err != nil { + return nil, err + } + + // If there's no existing data, the new data becomes the base + if len(oldBuf) == 0 { + // Copy: newBuf is owned by pebble and may be reused. + return bytes.Clone(newBuf), nil + } + + // Unmarshal existing data + if err := json.Unmarshal(oldBuf, &oldVal); err != nil { + return nil, err + } + + // Map to merge stats by SAN (Move) + mergedMap := make(map[string]ResultSummary) + for _, res := range oldVal.Results { + mergedMap[res.San] = res + } + + // Add or increment with new stats + for _, newRes := range newVal.Results { + if existing, found := mergedMap[newRes.San]; found { + existing.WhiteWins += newRes.WhiteWins + existing.BlackWins += newRes.BlackWins + existing.Draws += newRes.Draws + mergedMap[newRes.San] = existing + } else { + mergedMap[newRes.San] = newRes + } + } + + // Reconstruct the slice + var finalResults []ResultSummary + for _, res := range mergedMap { + finalResults = append(finalResults, res) + } + + return json.Marshal(PebbleValue{Results: finalResults}) +} diff --git a/explorer/query.http b/explorer/query.http new file mode 100644 index 0000000..187d551 --- /dev/null +++ b/explorer/query.http @@ -0,0 +1,5 @@ +### Query position stats by FEN (starting position) +GET http://localhost:8080/query?fen=rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR%20w%20KQkq%20-%200%201 + +### Query position stats after 1. e4 +GET http://localhost:8080/query?fen=rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR%20b%20KQkq%20-%200%201 diff --git a/frontend/src/components/Explorer.css b/frontend/src/components/Explorer.css new file mode 100644 index 0000000..883dadf --- /dev/null +++ b/frontend/src/components/Explorer.css @@ -0,0 +1,105 @@ +/* Opening explorer card. Occupies the coaching-tip slot in edit mode, so it + borrows the same surface/border tokens as .tip to read as the same object. */ + +.explorer { + display: flex; + flex-direction: column; + border: var(--border-w) solid var(--c-line); + border-radius: 0.5rem; + background: var(--c-surface); + overflow: hidden; +} + +.explorer-empty { + padding: 0.5rem 0.75rem; + text-align: center; + color: var(--c-gray-600); + font-size: 0.875rem; +} + +.explorer-list { + list-style: none; + margin: 0; + padding: 0; + max-height: 12rem; + overflow-y: auto; +} + +.explorer-row { + display: grid; + grid-template-columns: 3rem 3.25rem 3.5rem 1fr; + align-items: center; + gap: 0.5rem; + padding: 0.1875rem 0.75rem; + font-size: 0.8125rem; + line-height: 1.125rem; + cursor: pointer; +} + +.explorer-row:hover, +.explorer-row:focus-visible { + background: var(--c-gray-200); + outline: none; +} + +.explorer-row + .explorer-row { + border-top: var(--border-w) solid var(--c-line); +} + +.explorer-move { + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.explorer-eval { + font-variant-numeric: tabular-nums; + text-align: right; +} + +.explorer-games { + color: var(--c-gray-600); + font-variant-numeric: tabular-nums; + text-align: right; +} + +/* Stacked result bar: white wins / draws / black wins, left to right. */ +.explorer-bar { + display: flex; + height: 0.5rem; + border-radius: 0.25rem; + overflow: hidden; + border: var(--border-w) solid var(--c-line); +} + +.bar-white { + background: #f2f2f2; +} + +.bar-draw { + background: var(--c-gray-400, #9ca3af); +} + +.bar-black { + background: #3f3f46; +} + +/* Desktop: .area-usertip is a fixed grid row that scrolls its content. + With the list's own max-height that gave two scrollbars, so here the + card shrinks to fit the cell and only the list scrolls. */ +@media (min-width: 1024px) { + .area-usertip:has(> .explorer) { + display: flex; + flex-direction: column; + overflow: hidden; + } + + .explorer { + min-height: 0; + } + + .explorer-list { + flex: 1; + min-height: 0; + max-height: none; + } +} diff --git a/frontend/src/components/Explorer.tsx b/frontend/src/components/Explorer.tsx new file mode 100644 index 0000000..21c6578 --- /dev/null +++ b/frontend/src/components/Explorer.tsx @@ -0,0 +1,67 @@ +import { moveTotal } from '../services/explorer'; +import { formatEval } from '../services/chessdb'; +import { useExplorer } from '../hooks/useExplorer'; +import { useTrainerStore } from '../store/state'; +import './Explorer.css'; + +const pct = (n: number, d: number) => (d > 0 ? Math.round((n / d) * 100) : 0); + +// Opening explorer, shown in edit mode where the coaching tip sits during +// training. useExplorer tracks the selected position and refetches on +// every change; this component is the view. Clicking a row plays its +// move, same as playing it on the board. +export const Explorer = () => { + const { moves, error, evals } = useExplorer(); + const makeMove = useTrainerStore((s) => s.makeMove); + + return ( +
+ {error ? ( +
Couldn’t load explorer data
+ ) : moves === null ? ( +
Loading…
+ ) : moves.length === 0 ? ( +
No games from this position
+ ) : ( +
    + {moves.map((m) => { + const games = moveTotal(m); + const ev = evals[m.san]; + return ( +
  • makeMove(m.san)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + makeMove(m.san); + } + }} + > + {m.san} + + {ev === undefined ? '…' : ev === null ? '–' : formatEval(ev.score)} + + {games.toLocaleString()} + + + + + +
  • + ); + })} +
+ )} +
+ ); +}; diff --git a/frontend/src/components/UserTip.tsx b/frontend/src/components/UserTip.tsx index f44526a..07a034e 100644 --- a/frontend/src/components/UserTip.tsx +++ b/frontend/src/components/UserTip.tsx @@ -7,6 +7,7 @@ import { MousePointer, XIcon, } from 'lucide-react'; +import { Explorer } from './Explorer'; import './UserTip.css'; type TipAction = { @@ -86,7 +87,12 @@ export const UserTip = () => { if (repertoire.length == 0) return ; if (!trainingMethod) return ; - if (trainingMethod == 'edit') return null; + // In edit mode the coaching tip is irrelevant; surface the opening + // explorer for the currently selected position instead. The Explorer + // tracks selectedNode itself (useExplorer) and refetches on change. + if (trainingMethod == 'edit') { + return ; + } const chapter = repertoire.find((c) => c.uuid === selectedChapterId); if (!chapter) return null; diff --git a/frontend/src/hooks/useExplorer.ts b/frontend/src/hooks/useExplorer.ts new file mode 100644 index 0000000..031aaef --- /dev/null +++ b/frontend/src/hooks/useExplorer.ts @@ -0,0 +1,60 @@ +import { useEffect, useState } from 'react'; +import { INITIAL_FEN } from 'chessops/fen'; +import { useTrainerStore } from '../store/state'; +import { ExplorerMove, fetchExplorer, fullFen, moveTotal } from '../services/explorer'; +import { CloudEval, fetchCloudEval } from '../services/chessdb'; + +export type ExplorerResult = { + // null while a query is in flight (or before the first one). + moves: ExplorerMove[] | null; + error: boolean; + // Cloud eval per move SAN: missing while loading, null when chessdb + // doesn't know the position (or the request failed). + evals: Record; +}; + +// Queries the opening explorer for the currently selected position and +// refetches whenever the selected node changes while in edit mode. Only +// runs in edit mode; other training methods leave it idle. In-flight +// requests are aborted when the position changes or the component +// unmounts, so a slow response can't clobber a newer one. Once the moves +// arrive, each one's chessdb cloud eval is fetched in parallel. +export function useExplorer(): ExplorerResult { + const selectedNode = useTrainerStore((s) => s.selectedNode); + const trainingMethod = useTrainerStore((s) => s.trainingMethod); + + const [moves, setMoves] = useState(null); + const [error, setError] = useState(false); + const [evals, setEvals] = useState>({}); + + const active = trainingMethod === 'edit'; + // fetchExplorer normalizes board-only FENs; the null-node fallback is + // the starting position. + const fen = selectedNode?.data.fen ?? INITIAL_FEN; + + useEffect(() => { + if (!active) return; + const controller = new AbortController(); + const { signal } = controller; + setMoves(null); + setError(false); + setEvals({}); + fetchExplorer(fen, signal) + .then((m) => { + setMoves([...m].sort((a, b) => moveTotal(b) - moveTotal(a))); + for (const { san } of m) { + fetchCloudEval(fullFen(fen), san, signal) + .catch(() => null) + .then((e) => { + if (!signal.aborted) setEvals((prev) => ({ ...prev, [san]: e })); + }); + } + }) + .catch((e) => { + if (e?.name !== 'AbortError') setError(true); + }); + return () => controller.abort(); + }, [fen, active]); + + return { moves, error, evals }; +} diff --git a/frontend/src/services/chessdb.ts b/frontend/src/services/chessdb.ts new file mode 100644 index 0000000..4bb6cac --- /dev/null +++ b/frontend/src/services/chessdb.ts @@ -0,0 +1,74 @@ +import { Chess } from 'chessops/chess'; +import { makeFen, parseFen } from 'chessops/fen'; +import { parseSan } from 'chessops/san'; + +const CDB = 'https://www.chessdb.cn/cdb.php'; + +// Cloud evaluation of a position, in centipawns from White's point of view. +export type CloudEval = { score: number; depth: number }; + +// Move-tree nodes can carry "-" castling rights (built from a board-only +// root), and chessdb keys positions on castling: a "-" FEN finds a far +// shallower analysis, or none. Restore rights wherever king and rook +// still stand on their home squares. This is wrong only if a king or rook +// left and came back, which is rare in the opening. +function withCastling(fen: string): string { + const parts = fen.split(' '); + if (parts.length < 3 || parts[2] !== '-') return fen; + // Expand digits so each rank is 8 chars indexed a..h. + const rows = parts[0].split('/').map((r) => r.replace(/\d/g, (d) => '.'.repeat(Number(d)))); + const [black, white] = [rows[0], rows[7]]; + let rights = ''; + if (white[4] === 'K') { + if (white[7] === 'R') rights += 'K'; + if (white[0] === 'R') rights += 'Q'; + } + if (black[4] === 'k') { + if (black[7] === 'r') rights += 'k'; + if (black[0] === 'r') rights += 'q'; + } + parts[2] = rights || '-'; + return parts.join(' '); +} + +// FEN of the position after playing `san` from `fen`, or null if the move +// doesn't parse there. +function fenAfter(fen: string, san: string): string | null { + const setup = parseFen(withCastling(fen)); + if (!setup.isOk) return null; + const pos = Chess.fromSetup(setup.value); + if (!pos.isOk) return null; + const move = parseSan(pos.value, san); + if (!move) return null; + pos.value.play(move); + return makeFen(pos.value.toSetup()); +} + +// Query chessdb's analysis line (querypv) for the position after `san`. +// Returns null when chessdb doesn't know the position. chessdb scores are +// from the side to move, so flip to White's view. +export async function fetchCloudEval( + fen: string, + san: string, + signal?: AbortSignal, +): Promise { + const board = fenAfter(fen, san); + if (!board) return null; + const res = await fetch(`${CDB}?action=querypv&json=1&board=${encodeURIComponent(board)}`, { signal }); + if (!res.ok) return null; + const data = await res.json(); + if (data?.status !== 'ok' || typeof data.score !== 'number') return null; + const whiteToMove = board.split(' ')[1] === 'w'; + return { score: whiteToMove ? data.score : -data.score, depth: data.depth }; +} + +// chessdb reports mates as scores near ±30000 (30000 minus plies to mate). +const MATE = 20000; + +// Format an eval for display: "+0.35", "−1.20", "+M3". +export function formatEval(score: number): string { + const sign = score > 0 ? '+' : score < 0 ? '−' : ''; + const abs = Math.abs(score); + if (abs >= MATE) return `${sign}M${Math.ceil((30000 - abs) / 2)}`; + return `${sign}${(abs / 100).toFixed(2)}`; +} diff --git a/frontend/src/services/explorer.ts b/frontend/src/services/explorer.ts new file mode 100644 index 0000000..9957b52 --- /dev/null +++ b/frontend/src/services/explorer.ts @@ -0,0 +1,46 @@ +import { INITIAL_BOARD_FEN, INITIAL_FEN, makeFen, parseFen } from 'chessops/fen'; + +const API = import.meta.env.VITE_API_URL; + +// The move tree's root node stores a board-only FEN (no side-to-move, +// castling, etc.), which the explorer can't parse and which wouldn't +// match how games were indexed. Complete it to a full FEN before +// querying. The plain starting board maps to INITIAL_FEN specifically +// (with KQkq), since that's the position imported games are keyed by. +export function fullFen(fen: string): string { + const parts = fen.trim().split(/\s+/); + if (parts.length >= 4) return fen; + if (parts[0] === INITIAL_BOARD_FEN) return INITIAL_FEN; + const parsed = parseFen(fen); + return parsed.isOk ? makeFen(parsed.value) : fen; +} + +// One move played from the queried position, with aggregate results. +// Shape matches the explorer's ResultSummary (note: draws -> "draw"). +export type ExplorerMove = { + san: string; + whiteWins: number; + blackWins: number; + draw: number; +}; + +type ExplorerStats = { + results: ExplorerMove[]; +}; + +// Total games recorded for a move (white wins + draws + black wins). +export const moveTotal = (m: ExplorerMove) => m.whiteWins + m.draw + m.blackWins; + +// Fetch opening-explorer stats for a FEN via the backend proxy (cookie +// auth). Returns [] when the position is unknown or the request fails; +// aborts cleanly when the caller cancels (position changed). +export async function fetchExplorer(fen: string, signal?: AbortSignal): Promise { + const res = await fetch(`${API}/explorer?fen=${encodeURIComponent(fullFen(fen))}`, { + credentials: 'include', + signal, + }); + if (!res.ok) return []; + // The explorer returns JSON null for a position with no games. + const data = (await res.json()) as ExplorerStats | null; + return data?.results ?? []; +} diff --git a/frontend/src/util/chess.ts b/frontend/src/util/chess.ts index a5d83c1..c93f932 100644 --- a/frontend/src/util/chess.ts +++ b/frontend/src/util/chess.ts @@ -4,7 +4,7 @@ import { Dests, Key } from 'chessground/types'; import { Chess, Color, parseSquare, parseUci, Position, PositionError } from 'chessops'; import { chessgroundDests, chessgroundMove, scalachessCharPair } from 'chessops/compat'; -import { parseFen, makeFen, FenError } from 'chessops/fen'; +import { parseFen, makeFen, FenError, INITIAL_BOARD_FEN, INITIAL_FEN } from 'chessops/fen'; import { makeSan, makeSanAndPlay, parseSan } from 'chessops/san'; // leverages chessops library and its compatability module to transform a fen string into a legal move dictionary @@ -121,6 +121,10 @@ export function uciLineToSan(fen: string, uciLine: string): string[] { } export function positionFromFen(fen: string): [Chess, null] | [null, FenError | PositionError] { + // Chapter roots store a board-only FEN. parseFen fills missing fields + // with "no castling rights", which would propagate to every move added + // from the root. + if (fen.trim() === INITIAL_BOARD_FEN) fen = INITIAL_FEN; const [setup, error] = parseFen(fen).unwrap( (v) => [v, null], (e) => [null, e], diff --git a/scripts/import-games.sh b/scripts/import-games.sh new file mode 100755 index 0000000..7418883 --- /dev/null +++ b/scripts/import-games.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Import a PGN file into the opening explorer. +# +# Parses the PGN and PUTs each game to the explorer's /import endpoint. +# The explorer is internal-only, so this runs against its loopback port +# on the VPS (or wherever the explorer publishes 127.0.0.1:8090). +# +# Usage: +# scripts/import-games.sh [endpoint] +# +# Examples: +# scripts/import-games.sh games.pgn +# scripts/import-games.sh games.pgn http://127.0.0.1:8090/import +set -euo pipefail + +if [ "$#" -lt 1 ]; then + echo "usage: $0 [endpoint]" >&2 + exit 1 +fi + +PGN="$1" +ENDPOINT="${2:-http://127.0.0.1:8090/import}" + +if [ ! -f "$PGN" ]; then + echo "error: PGN file not found: $PGN" >&2 + exit 1 +fi + +# Resolve paths so the script works from any cwd. The importer is the Go +# CLI that ships with the explorer module. +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +EXPLORER_DIR="$SCRIPT_DIR/../explorer" +PGN_ABS="$(cd "$(dirname "$PGN")" && pwd)/$(basename "$PGN")" + +echo "importing $PGN_ABS -> $ENDPOINT" +cd "$EXPLORER_DIR" +go run ./cmd/import-pgn "$PGN_ABS" "$ENDPOINT" +echo "done"