Skip to content
Draft
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
165 changes: 165 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

Liwords (Woogles.io) is a web-based crossword board game platform with real-time multiplayer capabilities. The project consists of:

- **Backend API Server**: Go-based API server using Connect RPC (gRPC-compatible)
- **Frontend**: React/TypeScript UI built with RSBuild
- **Socket Server**: Separate Go service for real-time communication (in liwords-socket repo)
- **Game Engine**: Macondo library provides core game logic (in macondo repo)
- **Infrastructure**: PostgreSQL, Redis, NATS messaging, S3 storage

## Key Commands

### Frontend Development (liwords-ui/)
```bash
# Install dependencies
npm install

# Run development server
npm start

# Build production
npm run build

# Run tests
npm test

# Lint code
npm run lint

# Format code
npm run format

# Full pre-commit check
npm run isready
```

### Backend Development
```bash
# Run API server locally
go run cmd/liwords-api/*.go

# Run tests
go test ./...

# Generate code from proto/sql
go generate

# Run migrations up
migrate -database "postgres://postgres:pass@localhost:5432/liwords?sslmode=disable" -path db/migrations up

# Run migrations down
./migrate_down.sh
```

### Docker Development
```bash
# Full stack with Docker
docker compose up

# Services only (for hybrid development)
docker compose -f dc-local-services.yml up

# Register a bot user
./scripts/utilities/register-bot.sh BotUsername
```

## Architecture

### Service Communication
- **API Server** → **Socket Server**: Via NATS pub/sub for real-time events
- **Frontend** → **API Server**: Connect RPC over HTTP
- **Frontend** → **Socket Server**: WebSocket for real-time updates
- **API Server** → **PostgreSQL**: Primary data store
- **API Server** → **Redis**: Session storage, presence, chat history

### Key Patterns

1. **Code Generation**:
- Proto files → Go/TypeScript code via `buf generate`
- SQL queries → Go code via `sqlc generate`
- Run `go generate` after modifying .proto or .sql files

2. **Service Structure**:
- Each domain has a service in `pkg/` (e.g., `pkg/gameplay`, `pkg/tournament`)
- Services expose Connect RPC handlers
- Database access through generated sqlc code in `pkg/stores/`

3. **Real-time Events**:
- Game events flow through NATS
- Socket server broadcasts to connected clients
- Event types defined in `api/proto/ipc/`

4. **Authentication**:
- JWT tokens for API authentication
- Session cookies for web clients
- Bot accounts have `internal_bot` flag

### Important Directories

- `api/proto/`: Protocol buffer definitions
- `cmd/`: Entry points for various services
- `pkg/`: Core business logic and services
- `db/migrations/`: PostgreSQL schema migrations
- `db/queries/`: SQL queries for sqlc
- `liwords-ui/src/`: Frontend React code
- `rpc/`: Generated RPC code

## Testing

### Running Tests
```bash
# Backend unit tests
go test ./pkg/...

# Frontend tests
cd liwords-ui && npm test

# Integration tests (requires running services)
go test ./pkg/integration_testing/...
```

### Test Patterns
- Go tests use standard `testing` package
- Frontend uses Vitest
- Test data in `testdata/` directories
- Golden files for snapshot testing

## Common Development Tasks

### Adding a New RPC Endpoint
1. Define the service method in `api/proto/[service]/[service].proto`
2. Run `go generate` to generate code
3. Implement the handler in `pkg/[service]/service.go`
4. Add the service to the router in `cmd/liwords-api/main.go`

### Adding a Database Query
1. Write the SQL query in `db/queries/[domain].sql`
2. Run `go generate` to generate the Go code
3. Use the generated methods in your service

### Modifying the Database Schema
1. Create a new migration: `./gen_migration.sh [migration_name]`
2. Write the up/down SQL in `db/migrations/`
3. Run migrations: `migrate -database "..." -path db/migrations up`

## Environment Variables

Key environment variables (see docker-compose.yml for full list):
- `DB_*`: PostgreSQL connection settings
- `REDIS_URL`: Redis connection
- `NATS_URL`: NATS server URL
- `SECRET_KEY`: JWT signing key
- `MACONDO_DATA_PATH`: Path to game data files
- `AWS_*`: S3 configuration for uploads

## Debugging Tips

- Enable debug logging: `DEBUG=1`
- Access pprof: http://localhost:8001/debug/pprof/
- NATS monitoring: Connect to NATS and subscribe to `>` for all messages
- Database queries are logged when `DEBUG=1`
2 changes: 1 addition & 1 deletion aws/cfn/daily-maintenance.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Resources:
"name": "maintenance",
"command": [
"/opt/maintenance",
"integrations-refresher"
"integrations-refresher,partition-creator,cancelled-games-cleanup"
]
}
]
Expand Down
140 changes: 140 additions & 0 deletions cmd/maintenance/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
Expand Down Expand Up @@ -56,6 +57,12 @@ func main() {
case "sub-badge-updater":
err := SubBadgeUpdater()
log.Err(err).Msg("ran subBadgeUpdater")
case "partition-creator":
err := PartitionCreator()
log.Err(err).Msg("ran partitionCreator")
case "cancelled-games-cleanup":
err := CancelledGamesCleanup()
log.Err(err).Msg("ran cancelledGamesCleanup")
default:
log.Error().Str("command", command).Msg("command not recognized")
}
Expand Down Expand Up @@ -350,3 +357,136 @@ func updateBadges(q *models.Queries, pool *pgxpool.Pool) error {

return tx.Commit(ctx)
}

// PartitionCreator creates monthly partitions for the past_games table
// It only runs during the last 5 days of the month to minimize overhead
func PartitionCreator() error {
// Only run on days 26-31 of the month
now := time.Now()
if now.Day() < 26 {
log.Info().Int("day", now.Day()).Msg("skipping partition creation - not end of month")
return nil
}

log.Info().Msg("checking for partition creation")
cfg := &config.Config{}
cfg.Load(os.Args[1:])

if cfg.Debug {
zerolog.SetGlobalLevel(zerolog.DebugLevel)
} else {
zerolog.SetGlobalLevel(zerolog.InfoLevel)
}

dbCfg, err := pgxpool.ParseConfig(cfg.DBConnUri)
if err != nil {
return err
}
ctx := context.Background()
dbPool, err := pgxpool.NewWithConfig(ctx, dbCfg)
if err != nil {
return err
}
defer dbPool.Close()

// Check for existing partitions
rows, err := dbPool.Query(ctx, `
SELECT tablename
FROM pg_tables
WHERE schemaname = 'public'
AND tablename LIKE 'past_games_%'
ORDER BY tablename
`)
if err != nil {
return err
}
defer rows.Close()

existingPartitions := make(map[string]bool)
for rows.Next() {
var tableName string
if err := rows.Scan(&tableName); err != nil {
return err
}
existingPartitions[tableName] = true
}

// Create partitions for next 3 months if they don't exist
partitionsCreated := 0
for i := 0; i < 3; i++ {
targetDate := now.AddDate(0, i+1, 0)
year := targetDate.Year()
month := targetDate.Month()

partitionName := fmt.Sprintf("past_games_%04d_%02d", year, month)

if existingPartitions[partitionName] {
log.Debug().Str("partition", partitionName).Msg("partition already exists")
continue
}

// Calculate the start and end dates for the partition
startDate := time.Date(year, month, 1, 0, 0, 0, 0, time.UTC)
endDate := startDate.AddDate(0, 1, 0)

createSQL := fmt.Sprintf(`
CREATE TABLE %s PARTITION OF past_games
FOR VALUES FROM ('%s') TO ('%s')
`, partitionName, startDate.Format("2006-01-02"), endDate.Format("2006-01-02"))

_, err := dbPool.Exec(ctx, createSQL)
if err != nil {
log.Err(err).Str("partition", partitionName).Msg("failed to create partition")
return err
}

log.Info().Str("partition", partitionName).
Str("from", startDate.Format("2006-01-02")).
Str("to", endDate.Format("2006-01-02")).
Msg("created partition")
partitionsCreated++
}

log.Info().Int("partitions_created", partitionsCreated).Msg("partition creation complete")
return nil
}

// CancelledGamesCleanup deletes cancelled games older than 7 days
func CancelledGamesCleanup() error {
log.Info().Msg("starting cancelled games cleanup")
cfg := &config.Config{}
cfg.Load(os.Args[1:])

if cfg.Debug {
zerolog.SetGlobalLevel(zerolog.DebugLevel)
} else {
zerolog.SetGlobalLevel(zerolog.InfoLevel)
}

dbCfg, err := pgxpool.ParseConfig(cfg.DBConnUri)
if err != nil {
return err
}
ctx := context.Background()
dbPool, err := pgxpool.NewWithConfig(ctx, dbCfg)
if err != nil {
return err
}
defer dbPool.Close()

// Delete cancelled games older than 7 days
// game_end_reason = 7 is CANCELLED
result, err := dbPool.Exec(ctx, `
DELETE FROM games
WHERE game_end_reason = 7
AND created_at < NOW() - INTERVAL '7 days'
`)
if err != nil {
return err
}

rowsDeleted := result.RowsAffected()
log.Info().Int64("games_deleted", rowsDeleted).Msg("cancelled games cleanup complete")

return nil
}
5 changes: 5 additions & 0 deletions db/migrations/202508250421_partitioned_games.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
BEGIN;

DROP TABLE past_games;

COMMIT;
26 changes: 26 additions & 0 deletions db/migrations/202508250421_partitioned_games.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
BEGIN;

CREATE TABLE past_games (
-- id SERIAL PRIMARY KEY, -- experiment with this. we might not need it?
gid text NOT NULL,
created_at timestamp with time zone NOT NULL,
game_end_reason SMALLINT NOT NULL,
winner_idx SMALLINT, -- 0, 1 for first or second, -1 for draw. NULL if there's no winner.
game_request jsonb NOT NULL DEFAULT '{}',
game_document jsonb NOT NULL DEFAULT '{}',
stats jsonb NOT NULL DEFAULT '{}',
quickdata jsonb NOT NULL DEFAULT '{}',
type SMALLINT NOT NULL,
tournament_data jsonb -- can be null
) PARTITION BY RANGE (created_at);

CREATE INDEX idx_past_games_tournament_id
ON public.past_games USING hash(((tournament_data ->>'Id'::text)));
CREATE INDEX idx_past_games_gid ON public.past_games USING btree (gid);
CREATE INDEX idx_past_games_rematch_req_idx
ON public.past_games USING hash (((quickdata ->> 'o'::text)));
CREATE INDEX idx_past_games_created_at
ON public.past_games USING btree (created_at);


COMMIT;
18 changes: 18 additions & 0 deletions db/migrations/202508250511_improve_game_players.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
BEGIN;

-- Revert to original game_players structure
DROP TABLE IF EXISTS game_players;

CREATE TABLE game_players (
game_id integer NOT NULL,
player_id integer NOT NULL,
player_index SMALLINT,
FOREIGN KEY (game_id) REFERENCES games (id),
FOREIGN KEY (player_id) REFERENCES users (id),
PRIMARY KEY (game_id, player_id)
);

-- Remove migration status column
ALTER TABLE games DROP COLUMN IF EXISTS migration_status;

COMMIT;
Loading
Loading