You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1422 lines
40 KiB
Markdown
1422 lines
40 KiB
Markdown
# Know Foolery - Development Guidelines & Coding Standards
|
|
|
|
## Overview
|
|
|
|
This document establishes coding standards, development practices, and guidelines for the Know Foolery project to ensure consistency, maintainability, and quality across the codebase.
|
|
|
|
|
|
## Quality Assurance
|
|
|
|
### Testing Strategy
|
|
- **Unit Testing**: Go testing framework + Jest for frontend
|
|
- **Integration Testing**: testcontainers for database testing
|
|
- **E2E Testing**: Playwright for complete user journeys
|
|
- **Performance Testing**: k6 for load and stress testing
|
|
- **Security Testing**: Automated security scanning and penetration testing
|
|
|
|
### Code Quality
|
|
- **Type Safety**: TypeScript for frontend, Go's type system for backend
|
|
- **Code Review**: Mandatory peer review for all changes
|
|
- **Static Analysis**: Automated code quality checks
|
|
- **Documentation**: Comprehensive API and component documentation
|
|
|
|
|
|
## Code Organization
|
|
|
|
### Project Structure Standards
|
|
|
|
```
|
|
knowfoolery/
|
|
├── docs/ # Project documentation
|
|
├── backend/
|
|
│ ├── Domain/ # Domain logic (aggregates, value objects, repositories, services from Domain Driven Design)
|
|
│ ├── services/ # Microservices
|
|
│ │ ├── {service-name}/
|
|
│ │ │ ├── cmd/
|
|
│ │ │ │ └── main.go # Service entry point
|
|
│ │ │ ├── internal/
|
|
│ │ │ │ ├── handlers/ # Fiber HTTP handlers
|
|
│ │ │ │ ├── app/ # Business logic (commands, queries)
|
|
│ │ │ │ ├── middleware/ # Service-specific middleware
|
|
│ │ │ │ └── models/ # Ent models and schemas
|
|
│ │ │ ├── api/ # API definitions (OpenAPI/gRPC)
|
|
│ │ │ ├── config/ # Configuration management
|
|
│ │ │ ├── tests/ # Service-specific tests
|
|
│ │ │ ├── Dockerfile # Container definition
|
|
│ │ │ └── go.mod # Go module
|
|
│ │ └── shared/ # Shared packages
|
|
│ │ ├── auth/ # JWT middleware & Zitadel integration
|
|
│ │ ├── database/ # Ent database client
|
|
│ │ │ │ └── schema/ # Ent models and schemas
|
|
│ │ ├── observability/ # Metrics and tracing
|
|
│ │ ├── security/ # Security utilities
|
|
│ │ └── utils/ # Common utilities
|
|
├── frontend/
|
|
│ ├── packages/
|
|
│ │ ├── ui-components/ # Shared Gluestack UI components
|
|
│ │ ├── shared-logic/ # Business logic
|
|
│ │ ├── shared-types/ # TypeScript types
|
|
│ │ └── shared-utils/ # Utility functions
|
|
│ └── apps/
|
|
│ ├── web/ # React web app
|
|
│ ├── mobile/ # React Native app
|
|
│ └── desktop/ # Wails desktop app
|
|
└── infrastructure/ # DevOps and deployment
|
|
├── dev/ # Development setup
|
|
│ ├── docker-compose.yml # dev stack (hot reload, mounts)
|
|
│ └── api.env # dev env vars
|
|
└── prod/ # Production setup
|
|
```
|
|
|
|
### Naming Conventions
|
|
|
|
#### Go Code
|
|
```go
|
|
// Files: snake_case
|
|
user_service.go
|
|
jwt_middleware.go
|
|
database_client.go
|
|
|
|
// Packages: lowercase, single word when possible
|
|
package auth
|
|
package database
|
|
package observability
|
|
|
|
// Exported types: PascalCase
|
|
type GameService struct {}
|
|
type AuthClaims struct {}
|
|
type DatabaseConfig struct {}
|
|
|
|
// Unexported types: camelCase
|
|
type userRepository struct {}
|
|
type tokenValidator struct {}
|
|
|
|
// Functions: PascalCase (exported), camelCase (unexported)
|
|
func NewGameService() *GameService {}
|
|
func (s *GameService) StartGame() error {}
|
|
func validateToken() error {}
|
|
|
|
// Constants: PascalCase or SCREAMING_SNAKE_CASE for groups
|
|
const DefaultTimeout = 30 * time.Second
|
|
const (
|
|
StatusActive = "active"
|
|
StatusInactive = "inactive"
|
|
)
|
|
|
|
// Interfaces: noun or adjective + "er" suffix
|
|
type TokenValidator interface {}
|
|
type UserRepository interface {}
|
|
type ScoreCalculator interface {}
|
|
```
|
|
|
|
#### TypeScript Code
|
|
```typescript
|
|
// Files: kebab-case
|
|
game-card.tsx
|
|
user-service.ts
|
|
auth-context.tsx
|
|
|
|
// Components: PascalCase
|
|
export const GameCard: React.FC<GameCardProps> = () => {}
|
|
export const LeaderboardList: React.FC = () => {}
|
|
|
|
// Hooks: camelCase with "use" prefix
|
|
export const useGameSession = () => {}
|
|
export const useAuthentication = () => {}
|
|
|
|
// Functions: camelCase
|
|
export const calculateScore = () => {}
|
|
export const validateAnswer = () => {}
|
|
|
|
// Types/Interfaces: PascalCase
|
|
interface GameSessionProps {}
|
|
type AuthenticationState = 'loading' | 'authenticated' | 'unauthenticated'
|
|
|
|
// Constants: SCREAMING_SNAKE_CASE
|
|
export const MAX_ATTEMPTS = 3
|
|
export const SESSION_TIMEOUT = 30 * 60 * 1000 // 30 minutes
|
|
```
|
|
|
|
## Go Development Standards
|
|
|
|
### Fiber Framework Patterns
|
|
|
|
#### Service Structure
|
|
```go
|
|
// Service initialization with Fiber
|
|
package main
|
|
|
|
import (
|
|
"log"
|
|
"github.com/gofiber/fiber/v3"
|
|
"github.com/gofiber/fiber/v3/middleware/cors"
|
|
"github.com/gofiber/fiber/v3/middleware/logger"
|
|
"github.com/gofiber/fiber/v3/middleware/recover"
|
|
)
|
|
|
|
func main() {
|
|
// Create Fiber app with configuration
|
|
app := fiber.New(fiber.Config{
|
|
AppName: "Know Foolery Game Service",
|
|
ServerHeader: "Fiber",
|
|
ErrorHandler: customErrorHandler,
|
|
ReadTimeout: time.Second * 10,
|
|
WriteTimeout: time.Second * 10,
|
|
IdleTimeout: time.Second * 120,
|
|
})
|
|
|
|
// Global middleware
|
|
app.Use(logger.New())
|
|
app.Use(recover.New())
|
|
app.Use(cors.New(cors.Config{
|
|
AllowOrigins: "http://localhost:3000, https://app.knowfoolery.com",
|
|
AllowMethods: "GET,POST,PUT,DELETE,OPTIONS",
|
|
AllowHeaders: "Origin, Content-Type, Authorization",
|
|
}))
|
|
|
|
// Health check endpoint
|
|
app.Get("/health", healthCheck)
|
|
|
|
// API routes with versioning
|
|
api := app.Group("/api/v1")
|
|
setupGameRoutes(api)
|
|
|
|
// Start server
|
|
log.Fatal(app.Listen(":8080"))
|
|
}
|
|
|
|
func customErrorHandler(c *fiber.Ctx, err error) error {
|
|
code := fiber.StatusInternalServerError
|
|
message := "Internal Server Error"
|
|
|
|
if e, ok := err.(*fiber.Error); ok {
|
|
code = e.Code
|
|
message = e.Message
|
|
}
|
|
|
|
return c.Status(code).JSON(fiber.Map{
|
|
"error": true,
|
|
"message": message,
|
|
"code": code,
|
|
})
|
|
}
|
|
```
|
|
|
|
#### Handler Patterns
|
|
```go
|
|
// Handler structure and patterns
|
|
package handlers
|
|
|
|
import (
|
|
"github.com/gofiber/fiber/v3"
|
|
"knowfoolery/internal/services"
|
|
)
|
|
|
|
type GameHandler struct {
|
|
gameService *services.GameService
|
|
validator *validator.Validate
|
|
}
|
|
|
|
func NewGameHandler(gameService *services.GameService) *GameHandler {
|
|
return &GameHandler{
|
|
gameService: gameService,
|
|
validator: validator.New(),
|
|
}
|
|
}
|
|
|
|
// HTTP handler with proper error handling
|
|
func (h *GameHandler) StartGame(c *fiber.Ctx) error {
|
|
var req StartGameRequest
|
|
|
|
// Parse and validate request
|
|
if err := c.BodyParser(&req); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
|
"error": true,
|
|
"message": "Invalid request body",
|
|
})
|
|
}
|
|
|
|
if err := h.validator.Struct(&req); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
|
"error": true,
|
|
"message": "Validation failed",
|
|
"details": err.Error(),
|
|
})
|
|
}
|
|
|
|
// Extract user context from JWT middleware
|
|
userID := c.Locals("user_id").(string)
|
|
userRole := c.Locals("user_role").(string)
|
|
|
|
// Business logic
|
|
gameSession, err := h.gameService.StartGame(c.Context(), userID, req.PlayerName)
|
|
if err != nil {
|
|
return handleServiceError(c, err)
|
|
}
|
|
|
|
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
|
|
"success": true,
|
|
"data": gameSession,
|
|
})
|
|
}
|
|
|
|
// Centralized error handling
|
|
func handleServiceError(c *fiber.Ctx, err error) error {
|
|
switch {
|
|
case errors.Is(err, services.ErrInvalidInput):
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
|
"error": true,
|
|
"message": "Invalid input",
|
|
"details": err.Error(),
|
|
})
|
|
case errors.Is(err, services.ErrNotFound):
|
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
|
|
"error": true,
|
|
"message": "Resource not found",
|
|
})
|
|
case errors.Is(err, services.ErrUnauthorized):
|
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
|
"error": true,
|
|
"message": "Unauthorized access",
|
|
})
|
|
default:
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
|
"error": true,
|
|
"message": "Internal server error",
|
|
})
|
|
}
|
|
}
|
|
|
|
// Request/Response structures with validation
|
|
type StartGameRequest struct {
|
|
PlayerName string `json:"player_name" validate:"required,min=2,max=50,alphanum_space"`
|
|
}
|
|
|
|
type StartGameResponse struct {
|
|
SessionID string `json:"session_id"`
|
|
PlayerName string `json:"player_name"`
|
|
StartTime time.Time `json:"start_time"`
|
|
TimeRemaining int `json:"time_remaining_seconds"`
|
|
}
|
|
```
|
|
|
|
#### Middleware Patterns
|
|
```go
|
|
// Custom middleware for authentication
|
|
package middleware
|
|
|
|
import (
|
|
"github.com/gofiber/fiber/v3"
|
|
"knowfoolery/shared/auth"
|
|
)
|
|
|
|
func JWTMiddleware(zitadelRepo auth.ZitadelRepository) fiber.Handler {
|
|
return func(c *fiber.Ctx) error {
|
|
// Extract token from Authorization header
|
|
authHeader := c.Get("Authorization")
|
|
if authHeader == "" {
|
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
|
"error": true,
|
|
"message": "Authorization header required",
|
|
})
|
|
}
|
|
|
|
token := strings.TrimPrefix(authHeader, "Bearer ")
|
|
if token == authHeader {
|
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
|
"error": true,
|
|
"message": "Invalid authorization header format",
|
|
})
|
|
}
|
|
|
|
// Validate token
|
|
claims, err := zitadelRepo.ValidateToken(c.Context(), token)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
|
|
"error": true,
|
|
"message": "Invalid token",
|
|
})
|
|
}
|
|
|
|
// Set user context
|
|
c.Locals("user_id", claims.Subject)
|
|
c.Locals("user_email", claims.Email)
|
|
c.Locals("user_name", claims.Name)
|
|
c.Locals("user_roles", claims.Roles)
|
|
|
|
return c.Next()
|
|
}
|
|
}
|
|
|
|
// Rate limiting middleware
|
|
func RateLimitMiddleware(redisClient *redis.Client) fiber.Handler {
|
|
return func(c *fiber.Ctx) error {
|
|
userID := c.Locals("user_id")
|
|
clientIP := c.IP()
|
|
|
|
// Per-user rate limiting
|
|
if userID != nil {
|
|
userKey := fmt.Sprintf("rate_limit:user:%s", userID)
|
|
if !allowRequest(redisClient, userKey, 60, time.Minute) {
|
|
return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{
|
|
"error": true,
|
|
"message": "Rate limit exceeded for user",
|
|
})
|
|
}
|
|
}
|
|
|
|
// Per-IP rate limiting
|
|
ipKey := fmt.Sprintf("rate_limit:ip:%s", clientIP)
|
|
if !allowRequest(redisClient, ipKey, 100, time.Minute) {
|
|
return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{
|
|
"error": true,
|
|
"message": "Rate limit exceeded for IP",
|
|
})
|
|
}
|
|
|
|
return c.Next()
|
|
}
|
|
}
|
|
```
|
|
|
|
### Database Patterns with Ent
|
|
|
|
#### Schema Definition Standards
|
|
```go
|
|
// Ent schema with proper field validation and indexes
|
|
package schema
|
|
|
|
import (
|
|
"entgo.io/ent"
|
|
"entgo.io/ent/schema/edge"
|
|
"entgo.io/ent/schema/field"
|
|
"entgo.io/ent/schema/index"
|
|
"time"
|
|
)
|
|
|
|
type Question struct {
|
|
ent.Schema
|
|
}
|
|
|
|
func (Question) Fields() []ent.Field {
|
|
return []ent.Field{
|
|
field.String("theme").
|
|
NotEmpty().
|
|
MaxLen(100).
|
|
Comment("Question category/theme"),
|
|
field.Text("text").
|
|
NotEmpty().
|
|
MaxLen(1000).
|
|
Comment("The actual question text"),
|
|
field.String("answer").
|
|
NotEmpty().
|
|
MaxLen(500).
|
|
Comment("Correct answer"),
|
|
field.Text("hint").
|
|
Optional().
|
|
MaxLen(500).
|
|
Comment("Optional hint text"),
|
|
field.Enum("difficulty").
|
|
Values("easy", "medium", "hard").
|
|
Default("medium").
|
|
Comment("Question difficulty level"),
|
|
field.Bool("is_active").
|
|
Default(true).
|
|
Comment("Whether question is active"),
|
|
field.Time("created_at").
|
|
Default(time.Now).
|
|
Immutable().
|
|
Comment("Creation timestamp"),
|
|
field.Time("updated_at").
|
|
Default(time.Now).
|
|
UpdateDefault(time.Now).
|
|
Comment("Last update timestamp"),
|
|
}
|
|
}
|
|
|
|
func (Question) Indexes() []ent.Index {
|
|
return []ent.Index{
|
|
index.Fields("theme", "is_active"),
|
|
index.Fields("difficulty", "is_active"),
|
|
index.Fields("created_at"),
|
|
}
|
|
}
|
|
|
|
func (Question) Edges() []ent.Edge {
|
|
return []ent.Edge{
|
|
edge.To("attempts", QuestionAttempt.Type),
|
|
}
|
|
}
|
|
```
|
|
|
|
#### Service Layer Patterns
|
|
```go
|
|
// Service layer with proper error handling
|
|
package services
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"knowfoolery/internal/models"
|
|
"knowfoolery/ent"
|
|
)
|
|
|
|
var (
|
|
ErrInvalidInput = errors.New("invalid input")
|
|
ErrNotFound = errors.New("resource not found")
|
|
ErrUnauthorized = errors.New("unauthorized access")
|
|
ErrGameInProgress = errors.New("game already in progress")
|
|
ErrSessionExpired = errors.New("session expired")
|
|
)
|
|
|
|
type GameService struct {
|
|
client *ent.Client
|
|
questionSvc QuestionService
|
|
sessionSvc SessionService
|
|
validator *validator.Validate
|
|
}
|
|
|
|
func NewGameService(
|
|
client *ent.Client,
|
|
questionSvc QuestionService,
|
|
sessionSvc SessionService,
|
|
) *GameService {
|
|
return &GameService{
|
|
client: client,
|
|
questionSvc: questionSvc,
|
|
sessionSvc: sessionSvc,
|
|
validator: validator.New(),
|
|
}
|
|
}
|
|
|
|
func (s *GameService) StartGame(ctx context.Context, userID, playerName string) (*models.GameSession, error) {
|
|
// Validate input
|
|
if err := s.validatePlayerName(playerName); err != nil {
|
|
return nil, fmt.Errorf("%w: %v", ErrInvalidInput, err)
|
|
}
|
|
|
|
// Check for existing active session
|
|
activeSession, err := s.client.GameSession.
|
|
Query().
|
|
Where(gamesession.And(
|
|
gamesession.PlayerNameEQ(playerName),
|
|
gamesession.StatusEQ(gamesession.StatusActive),
|
|
)).
|
|
Only(ctx)
|
|
|
|
if err != nil && !ent.IsNotFound(err) {
|
|
return nil, fmt.Errorf("failed to check existing session: %w", err)
|
|
}
|
|
|
|
if activeSession != nil {
|
|
return nil, ErrGameInProgress
|
|
}
|
|
|
|
// Start database transaction
|
|
tx, err := s.client.Tx(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to start transaction: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
// Create new game session
|
|
session, err := tx.GameSession.
|
|
Create().
|
|
SetPlayerName(playerName).
|
|
SetTotalScore(0).
|
|
SetQuestionsAsked(0).
|
|
SetQuestionsCorrect(0).
|
|
SetHintsUsed(0).
|
|
SetStartTime(time.Now()).
|
|
SetStatus(gamesession.StatusActive).
|
|
Save(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create session: %w", err)
|
|
}
|
|
|
|
// Get first question
|
|
question, err := s.questionSvc.GetRandomQuestion(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get question: %w", err)
|
|
}
|
|
|
|
// Update session with first question
|
|
session, err = session.Update().
|
|
SetCurrentQuestionID(question.ID).
|
|
SetCurrentAttempts(0).
|
|
Save(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to update session: %w", err)
|
|
}
|
|
|
|
// Commit transaction
|
|
if err := tx.Commit(); err != nil {
|
|
return nil, fmt.Errorf("failed to commit transaction: %w", err)
|
|
}
|
|
|
|
return &models.GameSession{
|
|
ID: session.ID,
|
|
PlayerName: session.PlayerName,
|
|
TotalScore: session.TotalScore,
|
|
StartTime: session.StartTime,
|
|
Status: string(session.Status),
|
|
CurrentQuestion: &models.Question{
|
|
ID: question.ID,
|
|
Theme: question.Theme,
|
|
Text: question.Text,
|
|
Hint: question.Hint,
|
|
},
|
|
TimeRemaining: s.calculateTimeRemaining(session.StartTime),
|
|
}, nil
|
|
}
|
|
|
|
func (s *GameService) validatePlayerName(name string) error {
|
|
type PlayerNameValidation struct {
|
|
Name string `validate:"required,min=2,max=50,alphanum_space"`
|
|
}
|
|
|
|
return s.validator.Struct(&PlayerNameValidation{Name: name})
|
|
}
|
|
|
|
func (s *GameService) calculateTimeRemaining(startTime time.Time) int {
|
|
const sessionDuration = 30 * time.Minute
|
|
elapsed := time.Since(startTime)
|
|
remaining := sessionDuration - elapsed
|
|
|
|
if remaining < 0 {
|
|
return 0
|
|
}
|
|
|
|
return int(remaining.Seconds())
|
|
}
|
|
```
|
|
|
|
## TypeScript Development Standards
|
|
|
|
### React Component Patterns
|
|
|
|
#### Component Structure
|
|
```typescript
|
|
// Component with proper TypeScript types and patterns
|
|
import React, { useState, useEffect, useCallback } from 'react'
|
|
import {
|
|
Card,
|
|
VStack,
|
|
HStack,
|
|
Text,
|
|
Input,
|
|
Button,
|
|
Badge,
|
|
Progress
|
|
} from '@gluestack-ui/themed'
|
|
import { useGameSession } from '../hooks/useGameSession'
|
|
import { useTimer } from '../hooks/useTimer'
|
|
|
|
// Props interface with proper documentation
|
|
export interface GameCardProps {
|
|
/** The question to display */
|
|
question: string
|
|
/** Theme/category of the question */
|
|
theme: string
|
|
/** Time remaining in seconds */
|
|
timeRemaining: number
|
|
/** Number of attempts left */
|
|
attemptsLeft: number
|
|
/** Current player score */
|
|
currentScore: number
|
|
/** Loading state */
|
|
isLoading?: boolean
|
|
/** Callback when answer is submitted */
|
|
onSubmitAnswer: (answer: string) => Promise<void>
|
|
/** Callback when hint is requested */
|
|
onRequestHint: () => Promise<void>
|
|
/** Callback when time expires */
|
|
onTimeExpire?: () => void
|
|
}
|
|
|
|
/**
|
|
* GameCard component displays a quiz question with input and controls
|
|
*
|
|
* @param props - GameCard props
|
|
* @returns React component
|
|
*/
|
|
export const GameCard: React.FC<GameCardProps> = ({
|
|
question,
|
|
theme,
|
|
timeRemaining,
|
|
attemptsLeft,
|
|
currentScore,
|
|
isLoading = false,
|
|
onSubmitAnswer,
|
|
onRequestHint,
|
|
onTimeExpire
|
|
}) => {
|
|
// Local state with proper typing
|
|
const [answer, setAnswer] = useState<string>('')
|
|
const [isSubmitting, setIsSubmitting] = useState<boolean>(false)
|
|
|
|
// Custom hooks
|
|
const { formatTime } = useTimer()
|
|
|
|
// Memoized handlers
|
|
const handleSubmit = useCallback(async () => {
|
|
if (!answer.trim() || isSubmitting) return
|
|
|
|
setIsSubmitting(true)
|
|
try {
|
|
await onSubmitAnswer(answer.trim())
|
|
setAnswer('') // Clear input on successful submission
|
|
} catch (error) {
|
|
console.error('Failed to submit answer:', error)
|
|
// Error handling would be managed by parent component
|
|
} finally {
|
|
setIsSubmitting(false)
|
|
}
|
|
}, [answer, isSubmitting, onSubmitAnswer])
|
|
|
|
const handleHintRequest = useCallback(async () => {
|
|
if (isLoading) return
|
|
|
|
try {
|
|
await onRequestHint()
|
|
} catch (error) {
|
|
console.error('Failed to request hint:', error)
|
|
}
|
|
}, [isLoading, onRequestHint])
|
|
|
|
// Effects
|
|
useEffect(() => {
|
|
if (timeRemaining === 0 && onTimeExpire) {
|
|
onTimeExpire()
|
|
}
|
|
}, [timeRemaining, onTimeExpire])
|
|
|
|
// Computed values
|
|
const progressValue = ((30 * 60 - timeRemaining) / (30 * 60)) * 100
|
|
const isTimeWarning = timeRemaining <= 300 // 5 minutes
|
|
const isTimeCritical = timeRemaining <= 60 // 1 minute
|
|
|
|
return (
|
|
<Card size="lg" variant="elevated" m="$4">
|
|
<VStack space="md" p="$4">
|
|
{/* Header with theme and timer */}
|
|
<HStack justifyContent="space-between" alignItems="center">
|
|
<Badge
|
|
size="sm"
|
|
variant="solid"
|
|
action="info"
|
|
testID="theme-badge"
|
|
>
|
|
<Text color="$white" fontSize="$sm" fontWeight="$semibold">
|
|
{theme}
|
|
</Text>
|
|
</Badge>
|
|
<HStack space="sm" alignItems="center">
|
|
<Text
|
|
fontSize="$sm"
|
|
color={isTimeCritical ? "$error500" : isTimeWarning ? "$warning500" : "$textLight600"}
|
|
testID="timer-display"
|
|
>
|
|
⏱️ {formatTime(timeRemaining)}
|
|
</Text>
|
|
</HStack>
|
|
</HStack>
|
|
|
|
{/* Progress indicator */}
|
|
<Progress
|
|
value={progressValue}
|
|
size="sm"
|
|
testID="session-progress"
|
|
>
|
|
<ProgressFilledTrack />
|
|
</Progress>
|
|
|
|
{/* Question */}
|
|
<Text
|
|
fontSize="$lg"
|
|
fontWeight="$semibold"
|
|
lineHeight="$xl"
|
|
testID="question-text"
|
|
>
|
|
{question}
|
|
</Text>
|
|
|
|
{/* Answer input */}
|
|
<Input
|
|
size="lg"
|
|
isDisabled={isLoading || isSubmitting}
|
|
testID="answer-input"
|
|
>
|
|
<InputField
|
|
placeholder="Enter your answer..."
|
|
value={answer}
|
|
onChangeText={setAnswer}
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
onSubmitEditing={handleSubmit}
|
|
returnKeyType="send"
|
|
/>
|
|
</Input>
|
|
|
|
{/* Game stats */}
|
|
<HStack justifyContent="space-between" alignItems="center">
|
|
<Text fontSize="$sm" color="$textLight600" testID="attempts-counter">
|
|
Attempts left: {attemptsLeft}/3
|
|
</Text>
|
|
<Text fontSize="$sm" color="$textLight600" testID="score-display">
|
|
Score: {currentScore} points
|
|
</Text>
|
|
</HStack>
|
|
|
|
{/* Action buttons */}
|
|
<HStack space="sm">
|
|
<Button
|
|
size="lg"
|
|
variant="solid"
|
|
action="primary"
|
|
flex={1}
|
|
onPress={handleSubmit}
|
|
isDisabled={!answer.trim() || isLoading || isSubmitting}
|
|
testID="submit-button"
|
|
>
|
|
<ButtonText>
|
|
{isSubmitting ? 'Submitting...' : 'Submit Answer'}
|
|
</ButtonText>
|
|
</Button>
|
|
<Button
|
|
size="lg"
|
|
variant="outline"
|
|
action="secondary"
|
|
onPress={handleHintRequest}
|
|
isDisabled={isLoading || isSubmitting}
|
|
testID="hint-button"
|
|
>
|
|
<ButtonText>💡 Hint</ButtonText>
|
|
</Button>
|
|
</HStack>
|
|
</VStack>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
// Default export for dynamic imports
|
|
export default GameCard
|
|
```
|
|
|
|
#### Custom Hooks Pattern
|
|
```typescript
|
|
// Custom hook with proper TypeScript types
|
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
|
import { GameSessionService } from '../services/game-session-service'
|
|
|
|
interface UseGameSessionOptions {
|
|
playerName: string
|
|
onSessionEnd?: (finalScore: number) => void
|
|
onError?: (error: Error) => void
|
|
}
|
|
|
|
interface GameSessionState {
|
|
sessionId: string | null
|
|
currentQuestion: Question | null
|
|
score: number
|
|
attemptsLeft: number
|
|
timeRemaining: number
|
|
isLoading: boolean
|
|
error: string | null
|
|
}
|
|
|
|
interface GameSessionActions {
|
|
startGame: () => Promise<void>
|
|
submitAnswer: (answer: string) => Promise<boolean>
|
|
requestHint: () => Promise<string>
|
|
endGame: () => Promise<void>
|
|
}
|
|
|
|
export const useGameSession = (
|
|
options: UseGameSessionOptions
|
|
): [GameSessionState, GameSessionActions] => {
|
|
const [state, setState] = useState<GameSessionState>({
|
|
sessionId: null,
|
|
currentQuestion: null,
|
|
score: 0,
|
|
attemptsLeft: 3,
|
|
timeRemaining: 30 * 60, // 30 minutes
|
|
isLoading: false,
|
|
error: null,
|
|
})
|
|
|
|
const timerRef = useRef<NodeJS.Timeout | null>(null)
|
|
const gameService = useRef(new GameSessionService())
|
|
|
|
// Timer effect
|
|
useEffect(() => {
|
|
if (state.sessionId && state.timeRemaining > 0) {
|
|
timerRef.current = setInterval(() => {
|
|
setState(prev => {
|
|
const newTimeRemaining = prev.timeRemaining - 1
|
|
if (newTimeRemaining <= 0 && options.onSessionEnd) {
|
|
options.onSessionEnd(prev.score)
|
|
}
|
|
return { ...prev, timeRemaining: Math.max(0, newTimeRemaining) }
|
|
})
|
|
}, 1000)
|
|
}
|
|
|
|
return () => {
|
|
if (timerRef.current) {
|
|
clearInterval(timerRef.current)
|
|
}
|
|
}
|
|
}, [state.sessionId, state.timeRemaining, options.onSessionEnd])
|
|
|
|
// Actions
|
|
const startGame = useCallback(async () => {
|
|
setState(prev => ({ ...prev, isLoading: true, error: null }))
|
|
|
|
try {
|
|
const session = await gameService.current.startGame(options.playerName)
|
|
setState(prev => ({
|
|
...prev,
|
|
sessionId: session.sessionId,
|
|
currentQuestion: session.firstQuestion,
|
|
score: 0,
|
|
attemptsLeft: 3,
|
|
timeRemaining: session.timeRemaining,
|
|
isLoading: false,
|
|
}))
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : 'Failed to start game'
|
|
setState(prev => ({ ...prev, error: errorMessage, isLoading: false }))
|
|
options.onError?.(error instanceof Error ? error : new Error(errorMessage))
|
|
}
|
|
}, [options.playerName, options.onError])
|
|
|
|
const submitAnswer = useCallback(async (answer: string): Promise<boolean> => {
|
|
if (!state.sessionId || !state.currentQuestion) {
|
|
throw new Error('No active game session')
|
|
}
|
|
|
|
setState(prev => ({ ...prev, isLoading: true }))
|
|
|
|
try {
|
|
const result = await gameService.current.submitAnswer(
|
|
state.sessionId,
|
|
state.currentQuestion.id,
|
|
answer
|
|
)
|
|
|
|
setState(prev => ({
|
|
...prev,
|
|
score: result.newScore,
|
|
attemptsLeft: result.attemptsLeft,
|
|
currentQuestion: result.nextQuestion,
|
|
isLoading: false,
|
|
}))
|
|
|
|
return result.isCorrect
|
|
} catch (error) {
|
|
setState(prev => ({ ...prev, isLoading: false }))
|
|
throw error
|
|
}
|
|
}, [state.sessionId, state.currentQuestion])
|
|
|
|
const requestHint = useCallback(async (): Promise<string> => {
|
|
if (!state.sessionId || !state.currentQuestion) {
|
|
throw new Error('No active game session')
|
|
}
|
|
|
|
const hint = await gameService.current.requestHint(
|
|
state.sessionId,
|
|
state.currentQuestion.id
|
|
)
|
|
|
|
return hint
|
|
}, [state.sessionId, state.currentQuestion])
|
|
|
|
const endGame = useCallback(async () => {
|
|
if (!state.sessionId) return
|
|
|
|
await gameService.current.endGame(state.sessionId)
|
|
|
|
setState({
|
|
sessionId: null,
|
|
currentQuestion: null,
|
|
score: 0,
|
|
attemptsLeft: 3,
|
|
timeRemaining: 30 * 60,
|
|
isLoading: false,
|
|
error: null,
|
|
})
|
|
|
|
if (timerRef.current) {
|
|
clearInterval(timerRef.current)
|
|
}
|
|
}, [state.sessionId])
|
|
|
|
return [
|
|
state,
|
|
{
|
|
startGame,
|
|
submitAnswer,
|
|
requestHint,
|
|
endGame,
|
|
},
|
|
]
|
|
}
|
|
```
|
|
|
|
## Testing Standards
|
|
|
|
### Go Testing Patterns
|
|
```go
|
|
// Comprehensive testing with testify and testcontainers
|
|
package services_test
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/stretchr/testify/suite"
|
|
"github.com/testcontainers/testcontainers-go/modules/postgres"
|
|
|
|
"knowfoolery/internal/services"
|
|
"knowfoolery/ent/enttest"
|
|
)
|
|
|
|
type GameServiceTestSuite struct {
|
|
suite.Suite
|
|
ctx context.Context
|
|
gameService *services.GameService
|
|
client *ent.Client
|
|
}
|
|
|
|
func (suite *GameServiceTestSuite) SetupSuite() {
|
|
suite.ctx = context.Background()
|
|
|
|
// Start PostgreSQL container for integration tests
|
|
postgresContainer, err := postgres.RunContainer(suite.ctx,
|
|
testcontainers.WithImage("postgres:15-alpine"),
|
|
postgres.WithDatabase("testdb"),
|
|
postgres.WithUsername("testuser"),
|
|
postgres.WithPassword("testpass"),
|
|
)
|
|
require.NoError(suite.T(), err)
|
|
|
|
connStr, err := postgresContainer.ConnectionString(suite.ctx)
|
|
require.NoError(suite.T(), err)
|
|
|
|
// Create Ent client with test database
|
|
suite.client = enttest.Open(suite.T(), "postgres", connStr)
|
|
|
|
// Initialize service
|
|
suite.gameService = services.NewGameService(
|
|
suite.client,
|
|
services.NewQuestionService(suite.client),
|
|
services.NewSessionService(suite.client),
|
|
)
|
|
}
|
|
|
|
func (suite *GameServiceTestSuite) TearDownSuite() {
|
|
suite.client.Close()
|
|
}
|
|
|
|
func (suite *GameServiceTestSuite) SetupTest() {
|
|
// Clean database before each test
|
|
suite.client.GameSession.Delete().ExecX(suite.ctx)
|
|
suite.client.Question.Delete().ExecX(suite.ctx)
|
|
|
|
// Seed test data
|
|
suite.seedTestQuestions()
|
|
}
|
|
|
|
func (suite *GameServiceTestSuite) TestStartGame_Success() {
|
|
// Arrange
|
|
playerName := "TestPlayer"
|
|
|
|
// Act
|
|
session, err := suite.gameService.StartGame(suite.ctx, "user123", playerName)
|
|
|
|
// Assert
|
|
require.NoError(suite.T(), err)
|
|
assert.NotEmpty(suite.T(), session.ID)
|
|
assert.Equal(suite.T(), playerName, session.PlayerName)
|
|
assert.Equal(suite.T(), 0, session.TotalScore)
|
|
assert.NotNil(suite.T(), session.CurrentQuestion)
|
|
assert.True(suite.T(), session.TimeRemaining > 0)
|
|
}
|
|
|
|
func (suite *GameServiceTestSuite) TestStartGame_InvalidPlayerName() {
|
|
// Test cases for validation
|
|
testCases := []struct {
|
|
name string
|
|
playerName string
|
|
expectedErr string
|
|
}{
|
|
{"empty name", "", "required"},
|
|
{"too short", "a", "min"},
|
|
{"too long", strings.Repeat("a", 51), "max"},
|
|
{"invalid chars", "player@123", "alphanum_space"},
|
|
}
|
|
|
|
for _, tc := range testCases {
|
|
suite.T().Run(tc.name, func(t *testing.T) {
|
|
_, err := suite.gameService.StartGame(suite.ctx, "user123", tc.playerName)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), tc.expectedErr)
|
|
})
|
|
}
|
|
}
|
|
|
|
func (suite *GameServiceTestSuite) TestStartGame_ExistingActiveSession() {
|
|
// Arrange
|
|
playerName := "TestPlayer"
|
|
|
|
// Start first game
|
|
_, err := suite.gameService.StartGame(suite.ctx, "user123", playerName)
|
|
require.NoError(suite.T(), err)
|
|
|
|
// Act - try to start second game
|
|
_, err = suite.gameService.StartGame(suite.ctx, "user123", playerName)
|
|
|
|
// Assert
|
|
require.Error(suite.T(), err)
|
|
assert.Equal(suite.T(), services.ErrGameInProgress, err)
|
|
}
|
|
|
|
func (suite *GameServiceTestSuite) seedTestQuestions() {
|
|
questions := []struct {
|
|
theme string
|
|
text string
|
|
answer string
|
|
hint string
|
|
}{
|
|
{"Geography", "What is the capital of France?", "Paris", "City of Light"},
|
|
{"History", "Who painted the Mona Lisa?", "Leonardo da Vinci", "Renaissance artist"},
|
|
{"Science", "What is the largest planet?", "Jupiter", "Gas giant"},
|
|
}
|
|
|
|
for _, q := range questions {
|
|
suite.client.Question.Create().
|
|
SetTheme(q.theme).
|
|
SetText(q.text).
|
|
SetAnswer(q.answer).
|
|
SetHint(q.hint).
|
|
SetIsActive(true).
|
|
ExecX(suite.ctx)
|
|
}
|
|
}
|
|
|
|
func TestGameServiceSuite(t *testing.T) {
|
|
suite.Run(t, new(GameServiceTestSuite))
|
|
}
|
|
|
|
// Benchmark tests
|
|
func BenchmarkGameService_StartGame(b *testing.B) {
|
|
// Setup
|
|
ctx := context.Background()
|
|
client := enttest.Open(b, "sqlite3", "file:ent?mode=memory&cache=shared&_fk=1")
|
|
defer client.Close()
|
|
|
|
gameService := services.NewGameService(client, nil, nil)
|
|
|
|
// Benchmark
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
playerName := fmt.Sprintf("Player%d", i)
|
|
_, _ = gameService.StartGame(ctx, fmt.Sprintf("user%d", i), playerName)
|
|
}
|
|
}
|
|
```
|
|
|
|
### TypeScript Testing Patterns
|
|
```typescript
|
|
// React component testing with Jest and Testing Library
|
|
import React from 'react'
|
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
|
import { GluestackUIProvider } from '@gluestack-ui/themed'
|
|
import { GameCard, GameCardProps } from '../GameCard'
|
|
|
|
// Test wrapper for Gluestack UI
|
|
const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
|
|
<GluestackUIProvider>{children}</GluestackUIProvider>
|
|
)
|
|
|
|
// Mock props factory
|
|
const createMockProps = (overrides: Partial<GameCardProps> = {}): GameCardProps => ({
|
|
question: "What is the capital of France?",
|
|
theme: "Geography",
|
|
timeRemaining: 1800, // 30 minutes
|
|
attemptsLeft: 3,
|
|
currentScore: 0,
|
|
onSubmitAnswer: jest.fn(),
|
|
onRequestHint: jest.fn(),
|
|
...overrides,
|
|
})
|
|
|
|
describe('GameCard', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks()
|
|
})
|
|
|
|
describe('Rendering', () => {
|
|
it('should render question and theme correctly', () => {
|
|
const props = createMockProps()
|
|
|
|
render(<GameCard {...props} />, { wrapper: TestWrapper })
|
|
|
|
expect(screen.getByTestId('question-text')).toHaveTextContent(props.question)
|
|
expect(screen.getByTestId('theme-badge')).toHaveTextContent(props.theme)
|
|
})
|
|
|
|
it('should display timer in correct format', () => {
|
|
const props = createMockProps({ timeRemaining: 125 }) // 2:05
|
|
|
|
render(<GameCard {...props} />, { wrapper: TestWrapper })
|
|
|
|
expect(screen.getByTestId('timer-display')).toHaveTextContent('⏱️ 2:05')
|
|
})
|
|
|
|
it('should show attempts and score correctly', () => {
|
|
const props = createMockProps({ attemptsLeft: 2, currentScore: 5 })
|
|
|
|
render(<GameCard {...props} />, { wrapper: TestWrapper })
|
|
|
|
expect(screen.getByTestId('attempts-counter')).toHaveTextContent('Attempts left: 2/3')
|
|
expect(screen.getByTestId('score-display')).toHaveTextContent('Score: 5 points')
|
|
})
|
|
})
|
|
|
|
describe('User Interactions', () => {
|
|
it('should call onSubmitAnswer when submit button is clicked', async () => {
|
|
const props = createMockProps()
|
|
const mockOnSubmitAnswer = jest.fn().mockResolvedValue(undefined)
|
|
props.onSubmitAnswer = mockOnSubmitAnswer
|
|
|
|
render(<GameCard {...props} />, { wrapper: TestWrapper })
|
|
|
|
// Type an answer
|
|
const input = screen.getByTestId('answer-input')
|
|
fireEvent.changeText(input, 'Paris')
|
|
|
|
// Click submit
|
|
const submitButton = screen.getByTestId('submit-button')
|
|
fireEvent.press(submitButton)
|
|
|
|
await waitFor(() => {
|
|
expect(mockOnSubmitAnswer).toHaveBeenCalledWith('Paris')
|
|
})
|
|
})
|
|
|
|
it('should not submit empty answer', () => {
|
|
const props = createMockProps()
|
|
|
|
render(<GameCard {...props} />, { wrapper: TestWrapper })
|
|
|
|
const submitButton = screen.getByTestId('submit-button')
|
|
expect(submitButton).toBeDisabled()
|
|
})
|
|
|
|
it('should call onRequestHint when hint button is clicked', async () => {
|
|
const props = createMockProps()
|
|
const mockOnRequestHint = jest.fn().mockResolvedValue('City of Light')
|
|
props.onRequestHint = mockOnRequestHint
|
|
|
|
render(<GameCard {...props} />, { wrapper: TestWrapper })
|
|
|
|
const hintButton = screen.getByTestId('hint-button')
|
|
fireEvent.press(hintButton)
|
|
|
|
await waitFor(() => {
|
|
expect(mockOnRequestHint).toHaveBeenCalled()
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('Loading States', () => {
|
|
it('should disable inputs when loading', () => {
|
|
const props = createMockProps({ isLoading: true })
|
|
|
|
render(<GameCard {...props} />, { wrapper: TestWrapper })
|
|
|
|
expect(screen.getByTestId('answer-input')).toBeDisabled()
|
|
expect(screen.getByTestId('submit-button')).toBeDisabled()
|
|
expect(screen.getByTestId('hint-button')).toBeDisabled()
|
|
})
|
|
})
|
|
|
|
describe('Timer States', () => {
|
|
it('should show warning color when time is low', () => {
|
|
const props = createMockProps({ timeRemaining: 300 }) // 5 minutes
|
|
|
|
render(<GameCard {...props} />, { wrapper: TestWrapper })
|
|
|
|
const timer = screen.getByTestId('timer-display')
|
|
expect(timer).toHaveStyle({ color: expect.stringContaining('warning') })
|
|
})
|
|
|
|
it('should show critical color when time is very low', () => {
|
|
const props = createMockProps({ timeRemaining: 30 }) // 30 seconds
|
|
|
|
render(<GameCard {...props} />, { wrapper: TestWrapper })
|
|
|
|
const timer = screen.getByTestId('timer-display')
|
|
expect(timer).toHaveStyle({ color: expect.stringContaining('error') })
|
|
})
|
|
})
|
|
})
|
|
|
|
// Hook testing
|
|
import { renderHook, act } from '@testing-library/react'
|
|
import { useGameSession } from '../hooks/useGameSession'
|
|
|
|
// Mock the service
|
|
jest.mock('../services/game-session-service')
|
|
|
|
describe('useGameSession', () => {
|
|
const mockGameService = {
|
|
startGame: jest.fn(),
|
|
submitAnswer: jest.fn(),
|
|
requestHint: jest.fn(),
|
|
endGame: jest.fn(),
|
|
}
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks()
|
|
// Set up service mock
|
|
;(GameSessionService as jest.Mock).mockImplementation(() => mockGameService)
|
|
})
|
|
|
|
it('should start game successfully', async () => {
|
|
mockGameService.startGame.mockResolvedValue({
|
|
sessionId: 'session123',
|
|
firstQuestion: { id: 'q1', text: 'Test question' },
|
|
timeRemaining: 1800,
|
|
})
|
|
|
|
const { result } = renderHook(() =>
|
|
useGameSession({ playerName: 'TestPlayer' })
|
|
)
|
|
|
|
await act(async () => {
|
|
await result.current[1].startGame()
|
|
})
|
|
|
|
expect(result.current[0].sessionId).toBe('session123')
|
|
expect(result.current[0].currentQuestion).toBeDefined()
|
|
})
|
|
|
|
it('should handle errors during game start', async () => {
|
|
const mockError = new Error('Failed to start game')
|
|
mockGameService.startGame.mockRejectedValue(mockError)
|
|
|
|
const onError = jest.fn()
|
|
const { result } = renderHook(() =>
|
|
useGameSession({ playerName: 'TestPlayer', onError })
|
|
)
|
|
|
|
await act(async () => {
|
|
await result.current[1].startGame()
|
|
})
|
|
|
|
expect(result.current[0].error).toBe('Failed to start game')
|
|
expect(onError).toHaveBeenCalledWith(mockError)
|
|
})
|
|
})
|
|
```
|
|
|
|
## Code Quality Standards
|
|
|
|
### Linting and Formatting
|
|
```yaml
|
|
# .golangci.yml
|
|
run:
|
|
timeout: 5m
|
|
tests: true
|
|
|
|
linters:
|
|
enable:
|
|
- gofmt
|
|
- goimports
|
|
- govet
|
|
- errcheck
|
|
- staticcheck
|
|
- unused
|
|
- gosimple
|
|
- structcheck
|
|
- varcheck
|
|
- ineffassign
|
|
- deadcode
|
|
- typecheck
|
|
- gosec
|
|
- misspell
|
|
- lll
|
|
- unconvert
|
|
- dupl
|
|
- goconst
|
|
- gocyclo
|
|
|
|
linters-settings:
|
|
lll:
|
|
line-length: 120
|
|
gocyclo:
|
|
min-complexity: 15
|
|
dupl:
|
|
threshold: 150
|
|
|
|
issues:
|
|
exclude-rules:
|
|
- path: _test\.go
|
|
linters:
|
|
- gocyclo
|
|
- errcheck
|
|
- dupl
|
|
- gosec
|
|
```
|
|
|
|
```json
|
|
// .eslintrc.json
|
|
{
|
|
"extends": [
|
|
"@react-native",
|
|
"@typescript-eslint/recommended",
|
|
"prettier"
|
|
],
|
|
"parser": "@typescript-eslint/parser",
|
|
"plugins": ["@typescript-eslint", "react-hooks"],
|
|
"rules": {
|
|
"@typescript-eslint/explicit-function-return-type": "error",
|
|
"@typescript-eslint/no-unused-vars": "error",
|
|
"@typescript-eslint/prefer-const": "error",
|
|
"react-hooks/rules-of-hooks": "error",
|
|
"react-hooks/exhaustive-deps": "warn",
|
|
"prefer-const": "error",
|
|
"no-var": "error",
|
|
"object-shorthand": "error",
|
|
"prefer-template": "error"
|
|
}
|
|
}
|
|
```
|
|
|
|
### Git Workflow
|
|
```bash
|
|
# Branch naming conventions
|
|
feature/add-game-session-management
|
|
bugfix/fix-timer-display-issue
|
|
hotfix/security-vulnerability-patch
|
|
refactor/improve-database-queries
|
|
|
|
# Commit message format
|
|
type(scope): description
|
|
|
|
# Types: feat, fix, docs, style, refactor, test, chore
|
|
# Examples:
|
|
feat(game): add session timeout handling
|
|
fix(auth): resolve JWT token validation issue
|
|
docs(api): update authentication endpoint documentation
|
|
test(services): add comprehensive game service tests
|
|
```
|
|
|
|
This comprehensive development guidelines document ensures consistent, maintainable, and high-quality code across the Know Foolery project. |