diff --git a/.codecov.yml b/.codecov.yml new file mode 100644 index 0000000..6e00acf --- /dev/null +++ b/.codecov.yml @@ -0,0 +1,35 @@ +coverage: + precision: 2 + round: down + range: "80...100" + status: + project: + default: + target: 80% + threshold: 5% + base: auto + patch: + default: + target: 80% + threshold: 5% + base: auto + +parsers: + gcov: + branch_detection: + conditional: yes + loop: yes + method: no + macro: no + +comment: + layout: "reach,diff,flags,files,footer" + behavior: default + require_changes: false + +ignore: + - "cmd/main.go" + - "tests/" + - "**/*_test.go" + - "internal/services/stub_services.go" + - "demo_*.go" \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..7560a8e --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,39 @@ +version: 2 +updates: + # Enable version updates for Go + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "09:00" + open-pull-requests-limit: 10 + reviewers: + - "TuringProblem" + assignees: + - "TuringProblem" + commit-message: + prefix: "deps" + include: "scope" + labels: + - "dependencies" + - "go" + + # Enable version updates for GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + day: "monday" + time: "09:00" + open-pull-requests-limit: 5 + reviewers: + - "TuringProblem" + assignees: + - "TuringProblem" + commit-message: + prefix: "ci" + include: "scope" + labels: + - "dependencies" + - "github-actions" \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8d2575d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,134 @@ +name: CI + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +env: + GO_VERSION: '1.21' + CGO_ENABLED: 0 + +jobs: + test: + name: Test + runs-on: ubuntu-latest + strategy: + matrix: + go-version: [1.21, 1.22] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go ${{ matrix.go-version }} + uses: actions/setup-go@v4 + with: + go-version: ${{ matrix.go-version }} + cache: true + + - name: Cache Go modules + uses: actions/cache@v3 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Install dependencies + run: go mod download + + - name: Run tests with coverage + run: | + go test -v -race -coverprofile=coverage.out -covermode=atomic ./... + go test -v -race -coverprofile=coverage.out -covermode=atomic ./tests/... + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + file: ./coverage.out + flags: unittests + name: codecov-umbrella + fail_ci_if_error: false + verbose: true + token: ${{ secrets.CODECOV_TOKEN }} + + lint: + name: Lint + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + + - name: golangci-lint + uses: golangci/golangci-lint-action@v4 + with: + version: latest + args: --timeout=5m + + build: + name: Build + runs-on: ubuntu-latest + strategy: + matrix: + goos: [linux, darwin, windows] + goarch: [amd64, arm64] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + + - name: Build + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: 0 + run: | + go build -v -ldflags="-s -w" -o clisland-${{ matrix.goos }}-${{ matrix.goarch }} ./cmd/main.go + + - name: Upload build artifacts + uses: actions/upload-artifact@v3 + with: + name: clisland-${{ matrix.goos }}-${{ matrix.goarch }} + path: clisland-${{ matrix.goos }}-${{ matrix.goarch }} + + security: + name: Security Scan + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + + - name: Run gosec security scanner + uses: securecodewarrior/github-action-gosec@master + with: + args: '-fmt sarif -out results.sarif ./...' + + - name: Upload SARIF file + uses: github/codeql-action/upload-sarif@v2 + if: always() + with: + sarif_file: results.sarif \ No newline at end of file diff --git a/.github/workflows/qulty.yml b/.github/workflows/qulty.yml new file mode 100644 index 0000000..1fd9d9a --- /dev/null +++ b/.github/workflows/qulty.yml @@ -0,0 +1,26 @@ +name: QLTY.dev Analysis + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +jobs: + qulty: + name: QLTY.dev Analysis + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: QLTY.dev Analysis + uses: qulty-dev/github-action@v1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + # Optional: Add your QLTY.dev API key if you have one + # api_key: ${{ secrets.QLTY_API_KEY }} \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c7f3809 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,67 @@ +name: Release + +on: + push: + tags: + - 'v*' + +env: + GO_VERSION: '1.21' + CGO_ENABLED: 0 + +jobs: + release: + name: Release + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + + - name: Cache Go modules + uses: actions/cache@v3 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Build for multiple platforms + env: + CGO_ENABLED: 0 + run: | + VERSION=${GITHUB_REF#refs/tags/} + + # Build for different platforms + GOOS=linux GOARCH=amd64 go build -v -ldflags="-s -w -X main.version=$VERSION" -o clisland-linux-amd64 ./cmd/main.go + GOOS=linux GOARCH=arm64 go build -v -ldflags="-s -w -X main.version=$VERSION" -o clisland-linux-arm64 ./cmd/main.go + GOOS=darwin GOARCH=amd64 go build -v -ldflags="-s -w -X main.version=$VERSION" -o clisland-darwin-amd64 ./cmd/main.go + GOOS=darwin GOARCH=arm64 go build -v -ldflags="-s -w -X main.version=$VERSION" -o clisland-darwin-arm64 ./cmd/main.go + GOOS=windows GOARCH=amd64 go build -v -ldflags="-s -w -X main.version=$VERSION" -o clisland-windows-amd64.exe ./cmd/main.go + GOOS=windows GOARCH=arm64 go build -v -ldflags="-s -w -X main.version=$VERSION" -o clisland-windows-arm64.exe ./cmd/main.go + + - name: Create checksums + run: | + sha256sum clisland-* > checksums.txt + + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + files: | + clisland-* + checksums.txt + generate_release_notes: true + draft: false + prerelease: false \ No newline at end of file diff --git a/.gitignore b/.gitignore index 315add0..a7a451e 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,9 @@ dist/ build/ *.o *.a +clisland +clisland-* +checksums.txt # Environment files .env diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..78d46d5 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,144 @@ +run: + timeout: 5m + go: "1.21" + modules-download-mode: readonly + +linters-settings: + # https://github.com/golangci/golangci-lint/blob/master/.golangci.yml + govet: + check-shadowing: true + gocyclo: + min-complexity: 15 + maligned: + suggest-new: true + dupl: + threshold: 100 + goconst: + min-len: 2 + min-occurrences: 3 + misspell: + locale: US + lll: + line-length: 140 + goimports: + local-prefixes: github.com/TuringProblem/CLIsland + gocritic: + enabled-tags: + - diagnostic + - experimental + - opinionated + - performance + - style + disabled-checks: + - dupImport # https://github.com/go-critic/go-critic/issues/845 + - ifElseChain + - octalLiteral + - whyNoLint + - wrapperFunc + gosec: + excludes: + - G404 # Use of weak random number generator (math/rand instead of crypto/rand) + funlen: + lines: 100 + statements: 50 + gocognit: + min-complexity: 15 + noctx: + include: [] + exhaustive: + default-signifies-exhaustive: true + exhaustiveStruct: + struct-patterns: + - github.com/TuringProblem/CLIsland/internal/domain.* + errorlint: + errorf: true + asserts: true + comparison: true + +linters: + disable-all: true + enable: + # https://golangci-lint.run/usage/linters/ + - bodyclose + - deadcode + - depguard + - dogsled + - dupl + - errcheck + - exhaustive + - exhaustiveStruct + - exportloopref + - forbidigo + - funlen + - gci + - gocognit + - goconst + - gocritic + - gocyclo + - gofmt + - gofumpt + - goheader + - goimports + - gomnd + - gomodguard + - goprintffuncname + - gosec + - gosimple + - govet + - ineffassign + - lll + - makezero + - misspell + - nakedret + - noctx + - nolintlint + - prealloc + - predeclared + - revive + - staticcheck + - stylecheck + - thelper + - tparallel + - typecheck + - unconvert + - unparam + - unused + - varcheck + - whitespace + - wrapcheck + +issues: + exclude-rules: + - path: _test\.go + linters: + - gomnd + - goconst + - funlen + - gocognit + - gocyclo + - dupl + - thelper + - tparallel + - path: internal/services/stub_services\.go + linters: + - unused + - deadcode + - path: cmd/ + linters: + - gosec # CLI commands often need to read files + - gomnd # Magic numbers in CLI are often acceptable + - path: tests/ + linters: + - gosec + - gomnd + - goconst + - funlen + - gocognit + - gocyclo + - dupl + - thelper + - tparallel + - wrapcheck + + max-issues-per-linter: 0 + max-same-issues: 0 \ No newline at end of file diff --git a/Makefile b/Makefile index fdab4f8..09903a1 100644 --- a/Makefile +++ b/Makefile @@ -1,138 +1,103 @@ -# CLIsland Makefile -# Build, test, and development tasks for the Love Island CLI game -# Variables -BINARY_NAME=clisland -BUILD_DIR=build -MAIN_PACKAGE=./cmd -MODULE_NAME=github.com/TuringProblem/CLIsland +# Makefile for CLIsland -# Go build flags -LDFLAGS=-ldflags "-s -w" -BUILD_FLAGS=-trimpath +BINARY=clisland +PKG=./... +VERSION?=$(shell git describe --tags --always --dirty) + +.PHONY: all build run test test-unit test-integration test-e2e test-all fmt coverage clean lint security release help -# Default target -.PHONY: all all: build -# Build the application -.PHONY: build -build: deps - @echo "Building $(BINARY_NAME)..." - @mkdir -p $(BUILD_DIR) - go build $(BUILD_FLAGS) $(LDFLAGS) -o $(BINARY_NAME) $(MAIN_PACKAGE) - @echo "✅ Build complete: ./$(BINARY_NAME)" - -# Build for development (with debug info) -.PHONY: build-dev -build-dev: deps - @echo "Building $(BINARY_NAME) for development..." - @mkdir -p $(BUILD_DIR) - go build -o $(BINARY_NAME) $(MAIN_PACKAGE) - @echo "✅ Development build complete: ./$(BINARY_NAME)" - -# Run the application -.PHONY: run -run: deps - @echo "Running $(BINARY_NAME)..." - go run $(MAIN_PACKAGE) +help: ## Show this help message + @echo 'Usage: make [target]' + @echo '' + @echo 'Targets:' + @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-15s %s\n", $$1, $$2}' $(MAKEFILE_LIST) -# Clean build artifacts -.PHONY: clean -clean: - @echo "Cleaning build artifacts..." - @rm -f $(BINARY_NAME) - @rm -rf $(BUILD_DIR) - @echo "✅ Clean complete" - -# Run tests -.PHONY: test -test: - @echo "Running tests..." - go test -v ./... - -# Run tests with coverage -.PHONY: test-coverage -test-coverage: - @echo "Running tests with coverage..." - go test -v -coverprofile=coverage.out ./... +build: ## Build the application + go build -ldflags="-s -w -X main.version=$(VERSION)" -o $(BINARY) ./cmd/main.go + +build-all: ## Build for all platforms + @echo "Building for multiple platforms..." + GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X main.version=$(VERSION)" -o clisland-linux-amd64 ./cmd/main.go + GOOS=linux GOARCH=arm64 go build -ldflags="-s -w -X main.version=$(VERSION)" -o clisland-linux-arm64 ./cmd/main.go + GOOS=darwin GOARCH=amd64 go build -ldflags="-s -w -X main.version=$(VERSION)" -o clisland-darwin-amd64 ./cmd/main.go + GOOS=darwin GOARCH=arm64 go build -ldflags="-s -w -X main.version=$(VERSION)" -o clisland-darwin-arm64 ./cmd/main.go + GOOS=windows GOARCH=amd64 go build -ldflags="-s -w -X main.version=$(VERSION)" -o clisland-windows-amd64.exe ./cmd/main.go + GOOS=windows GOARCH=arm64 go build -ldflags="-s -w -X main.version=$(VERSION)" -o clisland-windows-arm64.exe ./cmd/main.go + +run: ## Run the application + go run ./cmd/main.go + +# Test targets +test: test-unit ## Run unit tests + +test-unit: ## Run unit tests only + @echo "Running unit tests..." + go test -v -race -coverprofile=coverage.out -covermode=atomic ./tests/unit/... + +test-integration: ## Run integration tests + @echo "Running integration tests..." + go test -v -race -coverprofile=coverage.out -covermode=atomic ./tests/integration/... + +test-e2e: ## Run end-to-end tests + @echo "Running end-to-end tests..." + go test -v -race -coverprofile=coverage.out -covermode=atomic ./tests/e2e/... + +test-all: test-unit test-integration test-e2e ## Run all tests + +test-coverage: ## Run tests with coverage report + go test -v -race -coverprofile=coverage.out -covermode=atomic ./... + go test -v -race -coverprofile=coverage.out -covermode=atomic ./tests/... + go tool cover -func=coverage.out go tool cover -html=coverage.out -o coverage.html - @echo "✅ Coverage report generated: coverage.html" - -# Format code -.PHONY: fmt -fmt: - @echo "Formatting code..." - go fmt ./... - @echo "✅ Code formatting complete" - -# Vet code -.PHONY: vet -vet: - @echo "Vetting code..." - go vet ./... - @echo "✅ Code vetting complete" - -# Run all quality checks -.PHONY: check -check: fmt vet test - @echo "✅ All quality checks passed" - -# Install the binary -.PHONY: install -install: build - @echo "Installing $(BINARY_NAME)..." - go install $(MAIN_PACKAGE) - @echo "✅ $(BINARY_NAME) installed to GOPATH" - -# Install dependencies -.PHONY: deps -deps: - @echo "Installing dependencies..." + +# Legacy test target for backward compatibility +test-legacy: ## Run legacy tests + go test -v $(PKG) + +fmt: ## Format code + gofmt -s -w . + goimports -w . + +lint: ## Run linter + golangci-lint run + +lint-fix: ## Run linter with auto-fix + golangci-lint run --fix + +security: ## Run security scan + gosec ./... + +coverage: ## Generate coverage report + go test -coverprofile=coverage.out $(PKG) + go tool cover -func=coverage.out + +# Clean build artifacts +clean: ## Clean build artifacts + rm -f $(BINARY) + rm -f clisland-* + rm -f coverage.out + rm -f coverage.html + rm -f checksums.txt + go clean -cache + +# Release targets +release: clean build-all ## Build release artifacts + @echo "Creating release artifacts..." + sha256sum clisland-* > checksums.txt + @echo "Release artifacts created:" + @ls -la clisland-* + @echo "Checksums:" + @cat checksums.txt + +# Development targets +dev-setup: ## Setup development environment go mod download - go mod tidy - go mod verify - @echo "✅ Dependencies installed and verified" + go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest + go install golang.org/x/tools/cmd/goimports@latest + go install github.com/securecodewarrior/gosec/v2/cmd/gosec@latest -# Build for multiple platforms -.PHONY: build-all -build-all: clean - @echo "Building for multiple platforms..." - @mkdir -p $(BUILD_DIR) - - # Linux - GOOS=linux GOARCH=amd64 go build $(BUILD_FLAGS) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 $(MAIN_PACKAGE) - - # macOS - GOOS=darwin GOARCH=amd64 go build $(BUILD_FLAGS) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-amd64 $(MAIN_PACKAGE) - GOOS=darwin GOARCH=arm64 go build $(BUILD_FLAGS) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 $(MAIN_PACKAGE) - - # Windows - GOOS=windows GOARCH=amd64 go build $(BUILD_FLAGS) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe $(MAIN_PACKAGE) - - @echo "✅ Multi-platform builds complete in $(BUILD_DIR)/" - -# Show help -.PHONY: help -help: - @echo "CLIsland Makefile - Available targets:" - @echo "" - @echo " build - Build the clisland executable (default)" - @echo " build-dev - Build with debug info" - @echo " build-all - Build for multiple platforms (Linux, macOS, Windows)" - @echo " run - Run the application directly" - @echo " test - Run all tests" - @echo " test-coverage- Run tests with coverage report" - @echo " fmt - Format code" - @echo " vet - Vet code for common issues" - @echo " deps - Install and tidy dependencies" - @echo " clean - Remove build artifacts" - @echo " check - Run fmt, vet, and test" - @echo " install - Install the binary to GOPATH" - @echo " help - Show this help message" - @echo "" - @echo "Examples:" - @echo " make build # Build the executable" - @echo " make run # Run the game" - @echo " make test # Run tests" - @echo " make check # Run all quality checks" +# CI/CD targets +ci: test-unit lint security ## Run CI checks locally \ No newline at end of file diff --git a/README.md b/README.md index 79d6118..4c09980 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,239 @@ - -![image](https://github.com/user-attachments/assets/a1c1c2af-3983-4bd5-9618-154d63c4e795) # CLIsland -Command-line Love-Island game (written in Go) + +[![Go Version](https://img.shields.io/badge/go-1.21+-blue.svg)](https://golang.org) +[![Build Status](https://github.com/TuringProblem/CLIsland/workflows/CI/badge.svg)](https://github.com/TuringProblem/CLIsland/actions) +[![Test Coverage](https://codecov.io/gh/TuringProblem/CLIsland/branch/main/graph/badge.svg)](https://codecov.io/gh/TuringProblem/CLIsland) +[![Go Report Card](https://goreportcard.com/badge/github.com/TuringProblem/CLIsland)](https://goreportcard.com/report/github.com/TuringProblem/CLIsland) +[![GoDoc](https://godoc.org/github.com/TuringProblem/CLIsland?status.svg)](https://godoc.org/github.com/TuringProblem/CLIsland) +[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +[![Release](https://img.shields.io/github/v/release/TuringProblem/CLIsland)](https://github.com/TuringProblem/CLIsland/releases) + +CLIsland is a command-line, single-player narrative game inspired by Love Island, written in Go. The game features branching dialogue, relationship management, and drama, all through a clean, modular, and testable architecture. + +## Features +- **Clean, Functional Architecture**: Modular, testable, and extensible codebase using domain-driven design principles. +- **Gameplay Loop**: Narrative-driven, state-machine-based gameplay with choices, relationships, and drama. +- **Separation of Concerns**: Clear separation between game engine, I/O, state, and scripting. +- **Testability**: Interfaces and dependency injection for easy unit testing. +- **DevOps-Friendly**: Linting, formatting, test coverage, and CI setup. +- **Configurable**: Characters, events, and dialogue trees defined via config files (JSON/YAML). +- **Command-line UI**: Minimal, testable CLI (TUI support planned). + +## Current Status ✅ +- **Core Architecture**: Complete with domain models, interfaces, and services +- **Game Engine**: Fully functional with stub implementations +- **CLI Interface**: Working game loop with character interactions and events +- **Testing**: Unit tests for core functionality +- **Build System**: Makefile and CI setup ready + +## Getting Started + +### Prerequisites +- Go 1.23+ +- (Optional) [golangci-lint](https://golangci-lint.run/) + +### Quick Start +```bash +# Build the game +make build + +# Run the game +./clisland +``` + +### Running the Game +```bash +# Option 1: Build and run +go build -o clisland ./cmd/main.go +./clisland + +# Option 2: Run directly +go run ./cmd/main.go +``` + +### Building +```bash +# Using make +make build + +# Using go directly +go build -o clisland ./cmd/main.go +``` + +### Testing +```bash +# Run all tests +go test ./... + +# Run specific test package +go test ./internal/services/ + +# Run with coverage +make coverage +``` + +### Formatting +```bash +# Format the code +make fmt +``` + +## Development + +### Setup Development Environment +```bash +# Install development tools +make dev-setup + +# Run all CI checks locally +make ci +``` + +### Available Make Targets +```bash +# Show all available targets +make help + +# Build for all platforms +make build-all + +# Run tests with coverage +make test-coverage + +# Run linter +make lint + +# Run security scan +make security + +# Create release artifacts +make release +``` + +## CI/CD + +This project uses GitHub Actions for continuous integration and deployment: + +- **Tests**: Run on every push and PR with Go 1.21 and 1.22 +- **Linting**: Uses golangci-lint with comprehensive rules +- **Security**: Automated security scanning with gosec +- **Coverage**: Code coverage uploaded to Codecov +- **Releases**: Automated releases on tag push + +### Workflow Status +- [![CI](https://github.com/TuringProblem/CLIsland/workflows/CI/badge.svg)](https://github.com/TuringProblem/CLIsland/actions/workflows/ci.yml) +- [![Release](https://github.com/TuringProblem/CLIsland/workflows/Release/badge.svg)](https://github.com/TuringProblem/CLIsland/actions/workflows/release.yml) + +## How to Play + +1. **Start the Game**: Run `./clisland` and enter your name +2. **Main Menu Options**: + - **Handle Current Event**: Make choices in story events + - **Interact with Character**: Talk, date, or challenge other contestants + - **Advance to Next Day**: Progress the story + - **View Detailed Stats**: Check your relationships and progress + - **Save Game**: Save your progress + - **Quit**: Exit the game + +3. **Gameplay Elements**: + - **Events**: Story-driven scenarios with multiple choices + - **Characters**: 3 initial contestants (Emma, James, Sophie) with unique personalities + - **Relationships**: Build affection and trust with characters + - **Stats**: Manage energy, confidence, popularity, and money + - **Days**: 30-day game cycle with different event types + +## Project Structure +``` +CLIsland/ +├── cmd/ +│ └── main.go # CLI entrypoint and game loop +├── internal/ +│ ├── domain/ +│ │ ├── models.go # Core domain models (Player, Character, Event, etc.) +│ │ └── interfaces.go # Service interfaces for dependency injection +│ ├── services/ +│ │ ├── game_engine.go # Core game logic implementation +│ │ ├── stub_services.go # Stub implementations for testing +│ │ └── game_engine_test.go # Unit tests +│ └── repositories/ +│ └── memory_repository.go # In-memory data storage +├── data/ # Game data (names, config, etc.) +├── utils/ # Utility functions +├── scripts/ # DevOps and helper scripts +└── Makefile # Build, test, and format commands +``` + +## Architecture + +### Domain Models +- **Player**: Main character with stats, personality, and relationships +- **Character**: Other contestants with unique traits and stats +- **Event**: Story events with choices and effects +- **Relationship**: Dynamic connections between characters +- **Effect**: Changes to game state (stats, relationships, etc.) + +### Services +- **GameEngine**: Core game logic and flow control +- **EventManager**: Event creation and management +- **CharacterManager**: Character operations +- **RelationshipManager**: Relationship dynamics and interactions +- **EffectProcessor**: Applying effects to game state +- **RequirementChecker**: Validating event/choice requirements +- **ConfigProvider**: Game configuration and data + +### Repositories +- **MemoryStateRepository**: In-memory game state storage +- **MemoryEventRepository**: In-memory event storage +- **MemoryCharacterRepository**: In-memory character storage + +## Development Tools +- **Makefile**: Common tasks for build, test, and coverage +- **Testing**: Unit tests with good coverage of core functionality + +## Extending the Game + +### Adding New Characters +Edit `internal/services/stub_services.go` in the `GetCharacterConfigs` method: +```go +{ + ID: "char_4", + Name: "NewCharacter", + Age: 25, + Personality: domain.Personality{ + Openness: 60.0, + // ... other traits + }, + // ... other properties +} +``` + +### Adding New Events +Edit `internal/services/stub_services.go` in the `GetEventConfigs` method: +```go +{ + ID: "event_3", + Title: "New Event", + Description: "Description of the new event", + Type: domain.EventTypeDrama, + Choices: []domain.Choice{ + // ... choices with effects + }, + IsActive: true, +} +``` + +### Adding New Effects +Extend the `EffectProcessor` in `internal/services/stub_services.go`: +```go +case domain.EffectTypeNewEffect: + // Handle new effect type +``` + +## Testing Strategy +- **Unit Tests**: Test individual services and components +- **Integration Tests**: Test service interactions +- **Mock Services**: Use stub implementations for isolated testing +- **Coverage**: Aim for >80% test coverage + +## License +MIT diff --git a/cmd/home.go b/cmd/home.go index 0cc6b10..19f0532 100644 --- a/cmd/home.go +++ b/cmd/home.go @@ -19,6 +19,7 @@ func start(person Person) { time.Sleep(time.Duration(2) * time.Second) loveMenu() + characterBuild() } func loveMenu() { diff --git a/cmd/main.go b/cmd/main.go index 629788d..0d20640 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,56 +1,344 @@ package main import ( + "bufio" + "context" "fmt" - //"math/rand" - //"time" + "os" + "strconv" + "strings" + + "github.com/TuringProblem/CLIsland/internal/domain" + "github.com/TuringProblem/CLIsland/internal/repositories" + "github.com/TuringProblem/CLIsland/internal/services" ) func main() { - printTag() - initialize() - TUIPrint("✔Hello, World") + // Initialize all services with stubbed implementations + stateRepo := repositories.NewMemoryStateRepository() + eventManager := services.NewStubEventManager() + characterManager := services.NewStubCharacterManager() + relationshipManager := services.NewStubRelationshipManager() + effectProcessor := services.NewStubEffectProcessor() + requirementChecker := services.NewStubRequirementChecker() + configProvider := services.NewStubConfigProvider() + + // Create game engine + gameEngine := services.NewGameEngineService( + stateRepo, + eventManager, + characterManager, + relationshipManager, + effectProcessor, + requirementChecker, + configProvider, + ) + + // Start the game + runGame(gameEngine) } -func initialize() { welcome() } -func welcome() { TUIPrint(CLEAR_SCREEN); which(versionResponse()) } +func runGame(gameEngine domain.GameEngine) { + ctx := context.Background() + scanner := bufio.NewScanner(os.Stdin) + + fmt.Println("🏝️ Welcome to CLIsland! 🏝️") + fmt.Println("A Love Island-inspired command-line adventure!") + fmt.Println() + + // Check if there's a saved game + _, err := gameEngine.GetCurrentState(ctx) + if err != nil { + // Start new game + fmt.Print("Enter your name: ") + scanner.Scan() + playerName := strings.TrimSpace(scanner.Text()) + if playerName == "" { + playerName = "Player" + } + + _, err = gameEngine.StartGame(ctx, playerName) + if err != nil { + fmt.Printf("Error starting game: %v\n", err) + return + } + fmt.Printf("Welcome to the villa, %s! Let's find love! 💕\n", playerName) + } else { + fmt.Println("Loading saved game...") + } + + // Main game loop + for { + gameState, err := gameEngine.GetCurrentState(ctx) + if err != nil { + fmt.Printf("Error getting game state: %v\n", err) + return + } + + if gameState.IsGameOver { + displayGameOver(gameState) + break + } -func versionResponse() int { - options() - var input int - fmt.Scan(&input) - return input + displayGameState(gameState) + displayMainMenu() + + fmt.Print("Enter your choice: ") + scanner.Scan() + choice := strings.TrimSpace(scanner.Text()) + + switch choice { + case "1": + handleCurrentEvent(ctx, gameEngine, scanner) + case "2": + handleCharacterInteraction(ctx, gameEngine, scanner) + case "3": + handleAdvanceDay(ctx, gameEngine) + case "4": + handleViewStats(gameState) + case "5": + handleSaveGame(ctx, gameEngine) + case "6": + fmt.Println("Thanks for playing CLIsland! 👋") + return + default: + fmt.Println("Invalid choice. Please try again.") + } + + fmt.Println() + } } -func welcomeScreen(person Person) { - TUIPrint(BlueBackground + "Welcome to the game " + person.Name + ResetBackground) +func displayGameState(gameState *domain.GameState) { + fmt.Printf("\n=== Day %d ===\n", gameState.GameDay) + fmt.Printf("Player: %s\n", gameState.Player.Name) + fmt.Printf("Energy: %.1f%% | Confidence: %.1f%% | Popularity: %.1f%% | Money: $%d\n", + gameState.Player.Stats.Energy, + gameState.Player.Stats.Confidence, + gameState.Player.Stats.Popularity, + gameState.Player.Stats.Money, + ) + + if gameState.CurrentEvent != nil { + fmt.Printf("\n📢 Current Event: %s\n", gameState.CurrentEvent.Title) + fmt.Printf(" %s\n", gameState.CurrentEvent.Description) + } + + fmt.Printf("\n👥 Available Characters (%d):\n", len(gameState.Characters)) + for _, character := range gameState.Characters { + if character.IsAvailable { + relationship, exists := gameState.Player.Relationships[character.ID] + affection := 0.0 + if exists { + affection = relationship.Affection + } + fmt.Printf(" %s (Age: %d) - Affection: %.1f\n", character.Name, character.Age, affection) + } + } } -func which(option int) { - switch option { +func displayMainMenu() { + fmt.Println("\n=== Main Menu ===") + fmt.Println("1. Handle current event") + fmt.Println("2. Interact with character") + fmt.Println("3. Advance to next day") + fmt.Println("4. View detailed stats") + fmt.Println("5. Save game") + fmt.Println("6. Quit") +} + +func handleCurrentEvent(ctx context.Context, gameEngine domain.GameEngine, scanner *bufio.Scanner) { + gameState, err := gameEngine.GetCurrentState(ctx) + if err != nil { + fmt.Printf("Error getting game state: %v\n", err) + return + } + + if gameState.CurrentEvent == nil { + fmt.Println("No current event to handle.") + return + } + + fmt.Printf("\n=== %s ===\n", gameState.CurrentEvent.Title) + fmt.Printf("%s\n\n", gameState.CurrentEvent.Description) + + fmt.Println("Available choices:") + for i, choice := range gameState.CurrentEvent.Choices { + fmt.Printf("%d. %s\n", i+1, choice.Text) + fmt.Printf(" %s\n", choice.Description) + } + + fmt.Print("\nEnter your choice (number): ") + scanner.Scan() + choiceStr := strings.TrimSpace(scanner.Text()) + choiceIndex, err := strconv.Atoi(choiceStr) + if err != nil || choiceIndex < 1 || choiceIndex > len(gameState.CurrentEvent.Choices) { + fmt.Println("Invalid choice.") + return + } + + selectedChoice := gameState.CurrentEvent.Choices[choiceIndex-1] + fmt.Printf("\nYou chose: %s\n", selectedChoice.Text) + + // Process the choice + _, err = gameEngine.ProcessChoice(ctx, selectedChoice.ID) + if err != nil { + fmt.Printf("Error processing choice: %v\n", err) + return + } + + fmt.Println("Choice processed successfully!") +} + +func handleCharacterInteraction(ctx context.Context, gameEngine domain.GameEngine, scanner *bufio.Scanner) { + characters, err := gameEngine.GetAvailableCharacters(ctx) + if err != nil { + fmt.Printf("Error getting characters: %v\n", err) + return + } + + if len(characters) == 0 { + fmt.Println("No characters available for interaction.") + return + } + + fmt.Println("\n=== Character Interaction ===") + fmt.Println("Available characters:") + for i, character := range characters { + fmt.Printf("%d. %s (Age: %d)\n", i+1, character.Name, character.Age) + } + + fmt.Print("Choose a character (number): ") + scanner.Scan() + charChoice := strings.TrimSpace(scanner.Text()) + charIndex, err := strconv.Atoi(charChoice) + if err != nil || charIndex < 1 || charIndex > len(characters) { + fmt.Println("Invalid character choice.") + return + } + + selectedCharacter := characters[charIndex-1] + + fmt.Println("\nInteraction types:") + fmt.Println("1. Conversation") + fmt.Println("2. Date") + fmt.Println("3. Challenge") + fmt.Println("4. Gift") + fmt.Println("5. Argument") + + fmt.Print("Choose interaction type (number): ") + scanner.Scan() + interactionChoice := strings.TrimSpace(scanner.Text()) + interactionIndex, err := strconv.Atoi(interactionChoice) + if err != nil || interactionIndex < 1 || interactionIndex > 5 { + fmt.Println("Invalid interaction choice.") + return + } + + var interactionType domain.InteractionType + switch interactionIndex { case 1: - setMode(1) - welcomeScreen(createExamplePerson()) + interactionType = domain.InteractionTypeConversation case 2: - TUIPrint(CLEAR_SCREEN) - setMode(2) - start(createRandomPerson()) - default: - fail() + + interactionType = domain.InteractionTypeDate + case 3: + interactionType = domain.InteractionTypeChallenge + case 4: + interactionType = domain.InteractionTypeGift + case 5: + interactionType = domain.InteractionTypeArgument } + + _, err = gameEngine.InteractWithCharacter(ctx, selectedCharacter.ID, interactionType) + if err != nil { + fmt.Printf("Error during interaction: %v\n", err) + return + + } + + fmt.Printf("You had a %s with %s!\n", strings.ToLower(string(interactionType)), selectedCharacter.Name) } -/** -func getInputs() { - fmt.Println("Grabbing inputs...") - var rand int = rand.Intn(10) - if rand > 5 { - time.Sleep(time.Duration(5) * time.Second) - fail() - } else { - time.Sleep(time.Duration(2) * time.Second) - success("inputs") - time.Sleep(time.Duration(1) * time.Second) +func handleAdvanceDay(ctx context.Context, gameEngine domain.GameEngine) { + fmt.Println("Advancing to the next day...") + + gameState, err := gameEngine.AdvanceDay(ctx) + if err != nil { + fmt.Printf("Error advancing day: %v\n", err) + return + } + + if gameState.IsGameOver { + displayGameOver(gameState) + return } + + fmt.Printf("Welcome to Day %d! 🌅\n", gameState.GameDay) + if gameState.CurrentEvent != nil { + fmt.Printf("New event: %s\n", gameState.CurrentEvent.Title) + } +} + +func handleViewStats(gameState *domain.GameState) { + fmt.Println("\n=== Detailed Stats ===") + fmt.Printf("Player: %s\n", gameState.Player.Name) + fmt.Printf("Age: %d\n", gameState.Player.Age) + fmt.Printf("Day: %d\n", gameState.GameDay) + fmt.Printf("Location: %s\n", gameState.Player.CurrentLocation) + + fmt.Println("\nStats:") + fmt.Printf(" Energy: %.1f%%\n", gameState.Player.Stats.Energy) + fmt.Printf(" Confidence: %.1f%%\n", gameState.Player.Stats.Confidence) + fmt.Printf(" Popularity: %.1f%%\n", gameState.Player.Stats.Popularity) + fmt.Printf(" Money: $%d\n", gameState.Player.Stats.Money) + + fmt.Println("\nPersonality:") + fmt.Printf(" Openness: %.1f%%\n", gameState.Player.Personality.Openness) + fmt.Printf(" Conscientiousness: %.1f%%\n", gameState.Player.Personality.Conscientiousness) + fmt.Printf(" Extraversion: %.1f%%\n", gameState.Player.Personality.Extraversion) + fmt.Printf(" Agreeableness: %.1f%%\n", gameState.Player.Personality.Agreeableness) + fmt.Printf(" Neuroticism: %.1f%%\n", gameState.Player.Personality.Neuroticism) + + fmt.Println("\nRelationships:") + for characterID, relationship := range gameState.Player.Relationships { + character, exists := gameState.Characters[characterID] + if exists { + fmt.Printf(" %s: Affection %.1f, Trust %.1f, Status: %s\n", + character.Name, + relationship.Affection, + relationship.Trust, + relationship.Status, + ) + } + } + + fmt.Printf("\nInventory (%d items):\n", len(gameState.Player.Inventory)) + for _, item := range gameState.Player.Inventory { + fmt.Printf(" %s: %s (Value: $%d)\n", item.Name, item.Description, item.Value) + } +} + +func handleSaveGame(ctx context.Context, gameEngine domain.GameEngine) { + err := gameEngine.SaveGame(ctx) + if err != nil { + fmt.Printf("Error saving game: %v\n", err) + return + } + fmt.Println("Game saved successfully! 💾") +} + +func displayGameOver(gameState *domain.GameState) { + fmt.Println("\n=== GAME OVER ===") + fmt.Printf("Congratulations! You've completed CLIsland!\n") + fmt.Printf("Final day: %d\n", gameState.GameDay) + + if gameState.Winner != "" { + if character, exists := gameState.Characters[gameState.Winner]; exists { + fmt.Printf("Winner: %s! 💕\n", character.Name) + } + } + + fmt.Println("\nFinal Stats:") + handleViewStats(gameState) } -**/ diff --git a/demo_person_generation.go b/demo_person_generation.go new file mode 100644 index 0000000..10104e2 --- /dev/null +++ b/demo_person_generation.go @@ -0,0 +1,73 @@ +package main + +import ( + "fmt" + + "github.com/TuringProblem/CLIsland/utils" +) + +func main() { + fmt.Println("=== Love Island Person Generation Demo ===\n") + + // Generate people for a male player + fmt.Println("🏝️ Characters for Male Player:") + malePlayerPeople := utils.GeneratePersonList("male") + for i, person := range malePlayerPeople { + fmt.Printf("%d. %s (%s, %d years old, %s)\n", + i+1, person.Name, person.Sex, person.Age, person.GetHeightInFeet()) + fmt.Printf(" Weight: %.1f kg (%.1f lbs)\n", person.GetWeightInKg(), person.GetWeightInLbs()) + fmt.Printf(" Interests: ") + for j, interest := range person.GetInterests() { + if j > 0 { + fmt.Print(", ") + } + fmt.Printf("%s (%d/10)", interest, person.GetInterestWeight(interest)) + } + fmt.Println("\n") + } + + fmt.Println("🏝️ Characters for Female Player:") + femalePlayerPeople := utils.GeneratePersonList("female") + for i, person := range femalePlayerPeople { + fmt.Printf("%d. %s (%s, %d years old, %s)\n", + i+1, person.Name, person.Sex, person.Age, person.GetHeightInFeet()) + fmt.Printf(" Weight: %.1f kg (%.1f lbs)\n", person.GetWeightInKg(), person.GetWeightInLbs()) + fmt.Printf(" Interests: ") + for j, interest := range person.GetInterests() { + if j > 0 { + fmt.Print(", ") + } + fmt.Printf("%s (%d/10)", interest, person.GetInterestWeight(interest)) + } + fmt.Println("\n") + } + + // Show some statistics + fmt.Println("=== Statistics ===") + fmt.Printf("Male player gets: %d characters\n", len(malePlayerPeople)) + fmt.Printf("Female player gets: %d characters\n", len(femalePlayerPeople)) + + // Count sexes for male player + maleCount := 0 + femaleCount := 0 + for _, person := range malePlayerPeople { + if person.Sex == "male" { + maleCount++ + } else { + femaleCount++ + } + } + fmt.Printf("Male player breakdown: %d males, %d females\n", maleCount, femaleCount) + + // Count sexes for female player + maleCount = 0 + femaleCount = 0 + for _, person := range femalePlayerPeople { + if person.Sex == "male" { + maleCount++ + } else { + femaleCount++ + } + } + fmt.Printf("Female player breakdown: %d males, %d females\n", maleCount, femaleCount) +} diff --git a/docs/MODULE_ORGANIZATION.md b/docs/MODULE_ORGANIZATION.md new file mode 100644 index 0000000..25e195d --- /dev/null +++ b/docs/MODULE_ORGANIZATION.md @@ -0,0 +1,293 @@ +# Module Organization Guide + +This document outlines the recommended structure and organization for the CLIsland project modules. + +## Project Structure + +``` +CLIsland/ +├── cmd/ # Command-line applications +│ ├── main.go # Main application entry point +│ ├── person.go # Person-related types and functions +│ ├── colors.go # Color constants for UI +│ ├── constants.go # General constants +│ ├── home.go # Home screen logic +│ ├── prompts.go # User prompts and menus +│ └── tag.go # UI tag functions +├── internal/ # Private application code +│ ├── domain/ # Domain models and interfaces +│ │ ├── models.go # Core domain models +│ │ └── interfaces.go # Service interfaces +│ ├── services/ # Business logic services +│ │ ├── game_engine.go # Core game logic +│ │ └── stub_services.go # Stub implementations +│ └── repositories/ # Data access layer +│ └── memory_repository.go +├── utils/ # Shared utility functions +│ ├── character_generator.go # Character generation +│ ├── person_generator.go # Person generation +│ ├── filters.go # Data filtering utilities +│ └── README.md # Utils documentation +├── data/ # Static data files +│ └── names/ # Name lists +│ ├── boys.txt +│ └── girls.txt +├── tests/ # Test files (organized by type) +│ ├── unit/ # Unit tests +│ │ └── utils_test.go +│ ├── integration/ # Integration tests +│ ├── e2e/ # End-to-end tests +│ ├── test_config.go # Test configuration +│ └── run_tests.sh # Test runner script +├── docs/ # Documentation +│ └── MODULE_ORGANIZATION.md +├── scripts/ # Build and deployment scripts +├── build/ # Build artifacts +├── go.mod # Go module definition +├── go.sum # Go module checksums +├── Makefile # Build automation +├── README.md # Project overview +└── LICENSE # License file +``` + +## Module Organization Principles + +### 1. Separation of Concerns + +- **cmd/**: Contains only the application entry points and CLI-specific code +- **internal/**: Contains all private application logic that shouldn't be imported by other projects +- **utils/**: Contains shared utility functions that could potentially be used by other projects +- **data/**: Contains static data files used by the application + +### 2. Package Naming Conventions + +- Use lowercase, single-word package names +- Avoid underscores or mixed caps +- Use descriptive names that indicate the package's purpose + +### 3. File Organization + +- Group related functionality in the same package +- Keep files focused on a single responsibility +- Use consistent naming patterns within packages + +## Testing Strategy + +### Test Organization + +``` +tests/ +├── unit/ # Unit tests for individual functions +├── integration/ # Integration tests for component interaction +├── e2e/ # End-to-end tests for full workflows +├── test_config.go # Shared test configuration +└── run_tests.sh # Test execution script +``` + +### Test Naming Conventions + +- Unit tests: `TestFunctionName` +- Integration tests: `TestComponent_Scenario` +- E2E tests: `TestWorkflow_EndToEnd` + +### Running Tests + +```bash +# Run all tests +make test-all + +# Run specific test types +make test-unit +make test-integration +make test-e2e + +# Run tests with custom configuration +TEST_VERBOSE=true make test-unit +TEST_RUN_SLOW=true make test-all +``` + +## Module Dependencies + +### Dependency Flow + +``` +cmd/ → internal/ → utils/ + ↓ + data/ +``` + +### Import Rules + +1. **cmd/ packages** can import: + - internal/ packages + - utils/ packages + - Standard library packages + +2. **internal/ packages** can import: + - Other internal/ packages + - utils/ packages + - Standard library packages + +3. **utils/ packages** can import: + - Other utils/ packages + - Standard library packages + - External dependencies + +4. **tests/ packages** can import: + - All application packages + - Testing packages + +## Adding New Modules + +### 1. Identify the Module Type + +- **Business Logic**: Place in `internal/services/` +- **Data Models**: Place in `internal/domain/` +- **Data Access**: Place in `internal/repositories/` +- **Utilities**: Place in `utils/` +- **CLI Commands**: Place in `cmd/` + +### 2. Create the Module Structure + +```go +// Example: internal/services/new_service.go +package services + +import ( + "context" + "github.com/TuringProblem/CLIsland/internal/domain" +) + +type NewService struct { + // Dependencies +} + +func NewNewService() *NewService { + return &NewService{} +} + +func (s *NewService) DoSomething(ctx context.Context) error { + // Implementation + return nil +} +``` + +### 3. Add Tests + +```go +// Example: tests/unit/new_service_test.go +package tests + +import ( + "testing" + "github.com/TuringProblem/CLIsland/internal/services" +) + +func TestNewService_DoSomething(t *testing.T) { + service := services.NewNewService() + + err := service.DoSomething(context.Background()) + if err != nil { + t.Errorf("Expected no error, got %v", err) + } +} +``` + +### 4. Update Documentation + +- Add module description to relevant README files +- Update this guide if new patterns are established + +## Best Practices + +### 1. Interface Design + +- Define interfaces in the package that uses them +- Keep interfaces small and focused +- Use interfaces for dependency injection + +### 2. Error Handling + +- Return errors rather than panicking +- Use wrapped errors for context +- Define custom error types when needed + +### 3. Configuration + +- Use environment variables for configuration +- Provide sensible defaults +- Validate configuration on startup + +### 4. Logging + +- Use structured logging +- Include context in log messages +- Use appropriate log levels + +### 5. Documentation + +- Document all exported functions +- Include usage examples +- Keep documentation up to date + +## Migration Guide + +### Moving from Old Structure + +If you have existing code that doesn't follow this structure: + +1. **Identify the module type** based on its purpose +2. **Move the code** to the appropriate directory +3. **Update imports** in all affected files +4. **Add tests** in the appropriate test directory +5. **Update documentation** to reflect the new structure + +### Example Migration + +```bash +# Before +src/ +├── character.go +├── character_test.go +└── main.go + +# After +utils/ +├── character_generator.go +└── README.md +tests/ +└── unit/ + └── utils_test.go +cmd/ +└── main.go +``` + +## Tools and Scripts + +### Makefile Targets + +- `make build`: Build the application +- `make test`: Run unit tests +- `make test-all`: Run all tests +- `make fmt`: Format code +- `make coverage`: Generate coverage report +- `make clean`: Clean build artifacts + +### Test Scripts + +- `./tests/run_tests.sh unit`: Run unit tests +- `./tests/run_tests.sh integration`: Run integration tests +- `./tests/run_tests.sh e2e`: Run end-to-end tests +- `./tests/run_tests.sh all`: Run all tests + +## Conclusion + +This organization structure promotes: + +- **Maintainability**: Clear separation of concerns +- **Testability**: Organized test structure +- **Scalability**: Easy to add new modules +- **Reusability**: Shared utilities in utils/ +- **Clarity**: Consistent naming and structure + +Follow these guidelines to maintain a clean, organized codebase that's easy to understand and extend. \ No newline at end of file diff --git a/docs/SETUP_GUIDE.md b/docs/SETUP_GUIDE.md new file mode 100644 index 0000000..f811e0c --- /dev/null +++ b/docs/SETUP_GUIDE.md @@ -0,0 +1,255 @@ +# Setup Guide + +This guide will help you set up the complete CI/CD pipeline for your Go project. + +## Prerequisites + +1. **GitHub Repository**: Your project should be hosted on GitHub +2. **Go 1.21+**: Ensure you're using Go 1.21 or later +3. **Codecov Account**: Set up at [codecov.io](https://codecov.io) + +## Step 1: GitHub Repository Setup + +### 1.1 Enable GitHub Actions +- Go to your repository settings +- Navigate to "Actions" → "General" +- Ensure "Allow all actions and reusable workflows" is selected + +### 1.2 Add Repository Secrets +Go to Settings → Secrets and variables → Actions and add: + +``` +CODECOV_TOKEN=your_codecov_token_here +``` + +To get your Codecov token: +1. Go to [codecov.io](https://codecov.io) +2. Add your repository +3. Copy the token from your repository settings + +## Step 2: Codecov Setup + +### 2.1 Add Repository to Codecov +1. Visit [codecov.io](https://codecov.io) +2. Sign in with GitHub +3. Add your repository +4. Copy the token and add it to GitHub secrets + +### 2.2 Configure Codecov +The `.codecov.yml` file is already configured with: +- 80% coverage target +- Coverage thresholds +- Ignored files (tests, stubs, etc.) + +## Step 3: Local Development Setup + +### 3.1 Install Development Tools +```bash +# Install all required tools +make dev-setup +``` + +This installs: +- `golangci-lint` - Code linting +- `goimports` - Import formatting +- `gosec` - Security scanning + +### 3.2 Verify Setup +```bash +# Run all CI checks locally +make ci + +# Check available targets +make help +``` + +## Step 4: GitHub Actions Workflows + +### 4.1 CI Workflow (`.github/workflows/ci.yml`) +This workflow runs on every push and PR: + +**Jobs:** +- **Test**: Runs tests with Go 1.21 and 1.22, uploads coverage to Codecov +- **Lint**: Runs golangci-lint with comprehensive rules +- **Build**: Builds for multiple platforms (Linux, macOS, Windows) +- **Security**: Runs gosec security scanner + +### 4.2 Release Workflow (`.github/workflows/release.yml`) +This workflow runs when you push a tag: + +**Features:** +- Builds for all platforms +- Creates GitHub release +- Generates checksums +- Auto-generates release notes + +### 4.3 QLTY.dev Workflow (`.github/workflows/qulty.yml`) +Optional workflow for code quality analysis: + +**Setup:** +1. Install the QLTY.dev GitHub app +2. Uncomment the API key line if you have one +3. Add `QLTY_API_KEY` to repository secrets + +## Step 5: Making Releases + +### 5.1 Create a Release +```bash +# Tag your release +git tag v1.0.0 + +# Push the tag +git push origin v1.0.0 +``` + +The release workflow will automatically: +- Build for all platforms +- Create a GitHub release +- Upload artifacts +- Generate release notes + +### 5.2 Local Release Testing +```bash +# Test release build locally +make release +``` + +## Step 6: Badges and Status + +### 6.1 Add Badges to README +The README already includes these badges: + +```markdown +[![Go Version](https://img.shields.io/badge/go-1.21+-blue.svg)](https://golang.org) +[![Build Status](https://github.com/TuringProblem/CLIsland/workflows/CI/badge.svg)](https://github.com/TuringProblem/CLIsland/actions) +[![Test Coverage](https://codecov.io/gh/TuringProblem/CLIsland/branch/main/graph/badge.svg)](https://codecov.io/gh/TuringProblem/CLIsland) +[![Go Report Card](https://goreportcard.com/badge/github.com/TuringProblem/CLIsland)](https://goreportcard.com/report/github.com/TuringProblem/CLIsland) +[![GoDoc](https://godoc.org/github.com/TuringProblem/CLIsland?status.svg)](https://godoc.org/github.com/TuringProblem/CLIsland) +[![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) +[![Release](https://img.shields.io/github/v/release/TuringProblem/CLIsland)](https://github.com/TuringProblem/CLIsland/releases) +``` + +### 6.2 Update Badge URLs +Replace `TuringProblem/CLIsland` with your repository name in the badge URLs. + +## Step 7: Configuration Files + +### 7.1 golangci-lint (`.golangci.yml`) +Configured with: +- 50+ linters enabled +- Custom rules for your project +- Exclusions for test files and stubs +- 5-minute timeout + +### 7.2 Codecov (`.codecov.yml`) +Configured with: +- 80% coverage target +- Coverage thresholds +- Ignored files +- Branch detection + +### 7.3 Dependabot (`.github/dependabot.yml`) +Automated dependency updates: +- Weekly Go module updates +- Weekly GitHub Actions updates +- Auto-assignment and labeling + +## Step 8: Troubleshooting + +### 8.1 Common Issues + +**Build Failures:** +```bash +# Check Go version +go version + +# Clean and rebuild +make clean +make build +``` + +**Lint Failures:** +```bash +# Run linter locally +make lint + +# Auto-fix what can be fixed +make lint-fix +``` + +**Test Failures:** +```bash +# Run tests locally +make test-unit + +# Run with coverage +make test-coverage +``` + +### 8.2 GitHub Actions Issues + +**Workflow Not Running:** +- Check repository settings → Actions → General +- Ensure workflows are enabled +- Check branch protection rules + +**Secret Issues:** +- Verify `CODECOV_TOKEN` is set correctly +- Check token permissions in Codecov + +### 8.3 Codecov Issues + +**Coverage Not Uploading:** +- Verify token is correct +- Check workflow logs for upload errors +- Ensure tests are generating coverage files + +## Step 9: Advanced Configuration + +### 9.1 Custom Linting Rules +Edit `.golangci.yml` to: +- Add/remove linters +- Adjust thresholds +- Add custom exclusions + +### 9.2 Coverage Thresholds +Edit `.codecov.yml` to: +- Change coverage targets +- Adjust thresholds +- Modify ignored files + +### 9.3 Build Matrix +Edit `.github/workflows/ci.yml` to: +- Add more Go versions +- Add more platforms +- Customize build flags + +## Step 10: Monitoring + +### 10.1 GitHub Actions +- Monitor workflow runs in Actions tab +- Set up notifications for failures +- Review build artifacts + +### 10.2 Codecov +- Monitor coverage trends +- Review coverage reports +- Set up coverage alerts + +### 10.3 Dependabot +- Review dependency updates +- Monitor security advisories +- Configure update schedules + +## Conclusion + +Your project now has: +- ✅ Automated testing on multiple Go versions +- ✅ Comprehensive linting with golangci-lint +- ✅ Security scanning with gosec +- ✅ Code coverage tracking with Codecov +- ✅ Automated releases with multi-platform builds +- ✅ Dependency updates with Dependabot +- ✅ Professional badges and documentation + +The CI/CD pipeline will help maintain code quality and automate the release process! \ No newline at end of file diff --git a/docs/TEST_ORGANIZATION_SUMMARY.md b/docs/TEST_ORGANIZATION_SUMMARY.md new file mode 100644 index 0000000..fc29937 --- /dev/null +++ b/docs/TEST_ORGANIZATION_SUMMARY.md @@ -0,0 +1,270 @@ +# Test Organization Summary + +This document summarizes the test organization structure implemented for the CLIsland project. + +## Test Structure Overview + +``` +tests/ +├── unit/ # Unit tests for individual functions +│ └── utils_test.go # Tests for utils package +├── integration/ # Integration tests (to be added) +├── e2e/ # End-to-end tests (to be added) +├── test_config.go # Shared test configuration +└── run_tests.sh # Test runner script +``` + +## Key Benefits + +### 1. **Organized Test Structure** +- **Unit Tests**: Test individual functions and methods in isolation +- **Integration Tests**: Test how components work together +- **E2E Tests**: Test complete user workflows + +### 2. **Easy Test Execution** +- Use Makefile targets: `make test-unit`, `make test-integration`, `make test-e2e` +- Use test runner script: `./tests/run_tests.sh [unit|integration|e2e|all]` +- Environment variable configuration for test behavior + +### 3. **Scalable Architecture** +- Easy to add new test types +- Centralized test configuration +- Consistent test patterns across the project + +## Current Implementation + +### Generated Functions + +1. **`GenerateCharacters(characters []string, playerSex string) []string`** + - Generates character names based on player sex + - Male player: 5 boys + 6 girls + - Female player: 6 boys + 5 girls + - Returns shuffled list of names + +2. **`GeneratePerson(sex string) Person`** + - Creates complete Person objects with realistic attributes + - Includes name, age, height, weight, sex, and interests + - Age range: 18-35 years + - Height: Realistic ranges for each sex + - Weight: BMI-based calculation + - Interests: 3-6 random interests with weights (1-10) + +3. **`GeneratePersonList(playerSex string) []Person`** + - Generates full list of Person objects + - Same logic as GenerateCharacters but returns complete objects + - Shuffled order for randomization + +### Person Object Features + +```go +type Person struct { + Name string + Age int + Height float64 // inches + Weight float64 // kg + Sex string + Interests Interests +} +``` + +**Available Methods:** +- `GetInterests() []Interest` - Returns list of interests +- `GetInterestWeight(interest Interest) int` - Returns weight of specific interest +- `HasInterest(interest Interest) bool` - Checks if person has interest +- `GetHeightInFeet() string` - Returns height in feet/inches format +- `GetHeightInCm() float64` - Returns height in centimeters +- `GetWeightInKg() float64` - Returns weight in kilograms +- `GetWeightInLbs() float64` - Returns weight in pounds + +## Usage Examples + +### Basic Character Generation +```go +import "github.com/TuringProblem/CLIsland/utils" + +// Generate character names +characters := utils.GenerateCharacters([]string{}, "male") +fmt.Printf("Generated %d characters: %v\n", len(characters), characters) +``` + +### Full Person Generation +```go +// Generate complete person objects +people := utils.GeneratePersonList("female") +for i, person := range people { + fmt.Printf("%d. %s (%s, %d years old, %s)\n", + i+1, person.Name, person.Sex, person.Age, person.GetHeightInFeet()) + fmt.Printf(" Weight: %.1f kg (%.1f lbs)\n", + person.GetWeightInKg(), person.GetWeightInLbs()) +} +``` + +### Individual Person Creation +```go +// Create a single person +person := utils.GeneratePerson("male") +fmt.Printf("Created: %s, %d years old, %s\n", + person.Name, person.Age, person.GetHeightInFeet()) +``` + +## Running Tests + +### Using Makefile +```bash +# Run unit tests only +make test-unit + +# Run all tests +make test-all + +# Run legacy tests (old structure) +make test-legacy +``` + +### Using Test Runner Script +```bash +# Run all tests +./tests/run_tests.sh all + +# Run specific test types +./tests/run_tests.sh unit +./tests/run_tests.sh integration +./tests/run_tests.sh e2e +``` + +### Using Go Directly +```bash +# Run unit tests +go test -v ./tests/unit/... + +# Run with coverage +go test -coverprofile=coverage.out ./tests/unit/... +go tool cover -func=coverage.out +``` + +## Test Configuration + +### Environment Variables +- `TEST_VERBOSE=true` - Enable verbose test output +- `TEST_RUN_SLOW=true` - Run slow tests (skipped by default) +- `TEST_DATA_PATH=path` - Specify test data directory + +### Test Utilities +```go +import "github.com/TuringProblem/CLIsland/tests" + +// Skip slow tests +tests.SkipIfSlowTest(t) + +// Skip if test data not available +tests.SkipIfNoTestData(t) + +// Get test configuration +config := tests.GetTestConfig() +``` + +## Adding New Tests + +### 1. Unit Tests +Create tests in `tests/unit/` for individual functions: +```go +// tests/unit/new_feature_test.go +package tests + +import ( + "testing" + "github.com/TuringProblem/CLIsland/utils" +) + +func TestNewFeature(t *testing.T) { + result := utils.NewFeature() + if result == nil { + t.Error("Expected result, got nil") + } +} +``` + +### 2. Integration Tests +Create tests in `tests/integration/` for component interaction: +```go +// tests/integration/service_integration_test.go +package tests + +import ( + "testing" + "github.com/TuringProblem/CLIsland/internal/services" +) + +func TestServiceIntegration(t *testing.T) { + service := services.NewService() + // Test service interactions +} +``` + +### 3. E2E Tests +Create tests in `tests/e2e/` for complete workflows: +```go +// tests/e2e/game_workflow_test.go +package tests + +import ( + "testing" + "github.com/TuringProblem/CLIsland/cmd" +) + +func TestCompleteGameWorkflow(t *testing.T) { + // Test complete game flow from start to finish +} +``` + +## Best Practices + +### 1. Test Naming +- Unit tests: `TestFunctionName` +- Integration tests: `TestComponent_Scenario` +- E2E tests: `TestWorkflow_EndToEnd` + +### 2. Test Organization +- Group related tests in the same file +- Use descriptive test names +- Include setup and teardown when needed + +### 3. Test Data +- Use realistic test data +- Avoid hardcoded values when possible +- Use test configuration for data paths + +### 4. Test Coverage +- Aim for high test coverage +- Focus on critical paths +- Test edge cases and error conditions + +## Migration from Old Structure + +If you have existing tests in the old structure: + +1. **Move test files** to appropriate directories in `tests/` +2. **Update package declarations** to use `package tests` +3. **Update imports** to use the new structure +4. **Run tests** to ensure everything works + +### Example Migration +```bash +# Before +utils/character_generator_test.go + +# After +tests/unit/utils_test.go +``` + +## Conclusion + +This test organization provides: + +- **Clear separation** of test types +- **Easy execution** with multiple options +- **Scalable structure** for future growth +- **Consistent patterns** across the project +- **Better maintainability** with organized code + +The structure supports the project's growth while maintaining clean, organized, and easily executable tests. \ No newline at end of file diff --git a/go.mod b/go.mod index 0bd7a6a..3535971 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/TuringProblem/CLIsland -go 1.23.2 +go 1.23 diff --git a/internal/domain/interfaces.go b/internal/domain/interfaces.go new file mode 100644 index 0000000..84c62c8 --- /dev/null +++ b/internal/domain/interfaces.go @@ -0,0 +1,112 @@ +package domain + +import ( + "context" +) + +type GameState struct { + Player *Player `json:"player"` + Characters map[string]*Character `json:"characters"` + Events map[string]*Event `json:"events"` + CurrentEvent *Event `json:"current_event"` + GameDay int `json:"game_day"` + IsGameOver bool `json:"is_game_over"` + Winner string `json:"winner,omitempty"` +} + +type GameEngine interface { + StartGame(ctx context.Context, playerName string) (*GameState, error) + ProcessChoice(ctx context.Context, choiceID string) (*GameState, error) + AdvanceDay(ctx context.Context) (*GameState, error) + EndGame(ctx context.Context) error + + GetCurrentState(ctx context.Context) (*GameState, error) + SaveGame(ctx context.Context) error + LoadGame(ctx context.Context) (*GameState, error) + + GetAvailableEvents(ctx context.Context) ([]*Event, error) + TriggerEvent(ctx context.Context, eventID string) (*GameState, error) + + GetAvailableCharacters(ctx context.Context) ([]*Character, error) + InteractWithCharacter(ctx context.Context, characterID string, interactionType InteractionType) (*GameState, error) +} + +type EventManager interface { + CreateEvent(ctx context.Context, event *Event) error + GetEvent(ctx context.Context, eventID string) (*Event, error) + UpdateEvent(ctx context.Context, event *Event) error + DeleteEvent(ctx context.Context, eventID string) error + ListEvents(ctx context.Context, eventType EventType) ([]*Event, error) + ValidateEvent(ctx context.Context, event *Event) error +} + +type CharacterManager interface { + CreateCharacter(ctx context.Context, character *Character) error + GetCharacter(ctx context.Context, characterID string) (*Character, error) + UpdateCharacter(ctx context.Context, character *Character) error + DeleteCharacter(ctx context.Context, characterID string) error + ListCharacters(ctx context.Context) ([]*Character, error) + UpdateCharacterStats(ctx context.Context, characterID string, stats CharacterStats) error +} + +type RelationshipManager interface { + GetRelationship(ctx context.Context, playerID, characterID string) (*Relationship, error) + UpdateRelationship(ctx context.Context, playerID, characterID string, relationship *Relationship) error + AddInteraction(ctx context.Context, playerID, characterID string, interaction *Interaction) error + CalculateCompatibility(ctx context.Context, player *Player, character *Character) (float64, error) + GetRelationshipHistory(ctx context.Context, playerID, characterID string) ([]*Interaction, error) +} + +type EffectProcessor interface { + ApplyEffect(ctx context.Context, effect *Effect, gameState *GameState) error + ApplyEffects(ctx context.Context, effects []*Effect, gameState *GameState) error + ValidateEffect(ctx context.Context, effect *Effect) error + ReverseEffect(ctx context.Context, effect *Effect, gameState *GameState) error +} + +type RequirementChecker interface { + CheckRequirement(ctx context.Context, requirement *Requirement, gameState *GameState) (bool, error) + CheckRequirements(ctx context.Context, requirements []*Requirement, gameState *GameState) (bool, error) + GetFailedRequirements(ctx context.Context, requirements []*Requirement, gameState *GameState) ([]*Requirement, error) +} + +type StateRepository interface { + Save(ctx context.Context, gameState *GameState) error + Load(ctx context.Context) (*GameState, error) + Delete(ctx context.Context) error + Exists(ctx context.Context) (bool, error) +} + +type EventRepository interface { + Save(ctx context.Context, event *Event) error + GetByID(ctx context.Context, eventID string) (*Event, error) + GetByType(ctx context.Context, eventType EventType) ([]*Event, error) + GetAll(ctx context.Context) ([]*Event, error) + Delete(ctx context.Context, eventID string) error +} + +type CharacterRepository interface { + Save(ctx context.Context, character *Character) error + GetByID(ctx context.Context, characterID string) (*Character, error) + GetAll(ctx context.Context) ([]*Character, error) + Delete(ctx context.Context, characterID string) error + Update(ctx context.Context, character *Character) error +} + +type ConfigProvider interface { + GetGameConfig(ctx context.Context) (*GameConfig, error) + GetEventConfigs(ctx context.Context) ([]*Event, error) + GetCharacterConfigs(ctx context.Context) ([]*Character, error) + GetItemConfigs(ctx context.Context) ([]*Item, error) +} + +type GameConfig struct { + MaxDays int `json:"max_days"` + StartingMoney int `json:"starting_money"` + StartingEnergy float64 `json:"starting_energy"` + StartingConfidence float64 `json:"starting_confidence"` + StartingPopularity float64 `json:"starting_popularity"` + MaxCharacters int `json:"max_characters"` + EliminationDay int `json:"elimination_day"` + FinaleDay int `json:"finale_day"` +} diff --git a/internal/domain/models.go b/internal/domain/models.go new file mode 100644 index 0000000..5d0c085 --- /dev/null +++ b/internal/domain/models.go @@ -0,0 +1,190 @@ +package domain + +import ( + "time" +) + +type Player struct { + ID string `json:"id"` + Name string `json:"name"` + Age int `json:"age"` + Personality Personality `json:"personality"` + Relationships map[string]Relationship `json:"relationships"` + Stats PlayerStats `json:"stats"` + Inventory []Item `json:"inventory"` + CurrentLocation string `json:"current_location"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Character struct { + ID string `json:"id"` + Name string `json:"name"` + Age int `json:"age"` + Personality Personality `json:"personality"` + Appearance Appearance `json:"appearance"` + Stats CharacterStats `json:"stats"` + IsAvailable bool `json:"is_available"` + CreatedAt time.Time `json:"created_at"` +} + +type Event struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Type EventType `json:"type"` + Choices []Choice `json:"choices"` + Requirements []Requirement `json:"requirements"` + Outcomes []Outcome `json:"outcomes"` + Duration time.Duration `json:"duration"` + IsActive bool `json:"is_active"` + CreatedAt time.Time `json:"created_at"` +} + +type Choice struct { + ID string `json:"id"` + Text string `json:"text"` + Description string `json:"description"` + Effects []Effect `json:"effects"` + Requirements []Requirement `json:"requirements"` +} + +type Outcome struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description"` + Effects []Effect `json:"effects"` + NextEventID string `json:"next_event_id,omitempty"` +} + +type Effect struct { + Type EffectType `json:"type"` + Target string `json:"target"` + Value float64 `json:"value"` + Description string `json:"description"` +} + +type Relationship struct { + CharacterID string `json:"character_id"` + Affection float64 `json:"affection"` // -100 to 100 + Trust float64 `json:"trust"` // 0 to 100 + Compatibility float64 `json:"compatibility"` // 0 to 100 + Status RelationshipStatus `json:"status"` + History []Interaction `json:"history"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Interaction struct { + Type InteractionType `json:"type"` + Description string `json:"description"` + Effects []Effect `json:"effects"` + Timestamp time.Time `json:"timestamp"` +} + +type Personality struct { + Openness float64 `json:"openness"` // 0-100 + Conscientiousness float64 `json:"conscientiousness"` // 0-100 + Extraversion float64 `json:"extraversion"` // 0-100 + Agreeableness float64 `json:"agreeableness"` // 0-100 + Neuroticism float64 `json:"neuroticism"` // 0-100 +} + +type Appearance struct { + Height int `json:"height"` + Build string `json:"build"` + HairColor string `json:"hair_color"` + EyeColor string `json:"eye_color"` + Style string `json:"style"` +} + +type PlayerStats struct { + Popularity float64 `json:"popularity"` // 0-100 + Confidence float64 `json:"confidence"` // 0-100 + Energy float64 `json:"energy"` // 0-100 + Money int `json:"money"` + DayNumber int `json:"day_number"` +} + +type CharacterStats struct { + Popularity float64 `json:"popularity"` // 0-100 + Energy float64 `json:"energy"` // 0-100 + Stress float64 `json:"stress"` // 0-100 +} + +type Item struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Type ItemType `json:"type"` + Value int `json:"value"` +} + +type Requirement struct { + Type RequirementType `json:"type"` + Target string `json:"target"` + Value float64 `json:"value"` + Operator string `json:"operator"` // "eq", "gt", "lt", "gte", "lte" +} + +type EventType string + +const ( + EventTypeChallenge EventType = "challenge" + EventTypeDate EventType = "date" + EventTypeElimination EventType = "elimination" + EventTypeDrama EventType = "drama" + EventTypeRecoupling EventType = "recoupling" +) + +type EffectType string + +const ( + EffectTypeAffection EffectType = "affection" + EffectTypeTrust EffectType = "trust" + EffectTypePopularity EffectType = "popularity" + EffectTypeConfidence EffectType = "confidence" + EffectTypeEnergy EffectType = "energy" + EffectTypeMoney EffectType = "money" + EffectTypeItem EffectType = "item" +) + +type RelationshipStatus string + +const ( + RelationshipStatusSingle RelationshipStatus = "single" + RelationshipStatusCoupled RelationshipStatus = "coupled" + RelationshipStatusExclusive RelationshipStatus = "exclusive" + RelationshipStatusMarried RelationshipStatus = "married" +) + +type InteractionType string + +const ( + InteractionTypeConversation InteractionType = "conversation" + InteractionTypeDate InteractionType = "date" + InteractionTypeChallenge InteractionType = "challenge" + InteractionTypeGift InteractionType = "gift" + InteractionTypeArgument InteractionType = "argument" +) + +type ItemType string + +const ( + ItemTypeClothing ItemType = "clothing" + ItemTypeAccessory ItemType = "accessory" + ItemTypeGift ItemType = "gift" + ItemTypeConsumable ItemType = "consumable" +) + +type RequirementType string + +const ( + RequirementTypeAffection RequirementType = "affection" + RequirementTypeTrust RequirementType = "trust" + RequirementTypePopularity RequirementType = "popularity" + RequirementTypeConfidence RequirementType = "confidence" + RequirementTypeEnergy RequirementType = "energy" + RequirementTypeMoney RequirementType = "money" + RequirementTypeItem RequirementType = "item" + RequirementTypeDayNumber RequirementType = "day_number" +) diff --git a/internal/repositories/memory_repository.go b/internal/repositories/memory_repository.go new file mode 100644 index 0000000..2e2f102 --- /dev/null +++ b/internal/repositories/memory_repository.go @@ -0,0 +1,156 @@ +package repositories + +import ( + "context" + "fmt" + "sync" + + "github.com/TuringProblem/CLIsland/internal/domain" +) + +type MemoryStateRepository struct { + gameState *domain.GameState + mu sync.RWMutex +} + +func NewMemoryStateRepository() *MemoryStateRepository { + return &MemoryStateRepository{} +} + +func (m *MemoryStateRepository) Save(ctx context.Context, gameState *domain.GameState) error { + m.mu.Lock() + defer m.mu.Unlock() + m.gameState = gameState + return nil +} + +func (m *MemoryStateRepository) Load(ctx context.Context) (*domain.GameState, error) { + m.mu.RLock() + defer m.mu.RUnlock() + if m.gameState == nil { + return nil, fmt.Errorf("no game state found") + } + return m.gameState, nil +} + +func (m *MemoryStateRepository) Delete(ctx context.Context) error { + m.mu.Lock() + defer m.mu.Unlock() + m.gameState = nil + return nil +} + +func (m *MemoryStateRepository) Exists(ctx context.Context) (bool, error) { + m.mu.RLock() + defer m.mu.RUnlock() + return m.gameState != nil, nil +} + +type MemoryEventRepository struct { + events map[string]*domain.Event + mu sync.RWMutex +} + +func NewMemoryEventRepository() *MemoryEventRepository { + return &MemoryEventRepository{ + events: make(map[string]*domain.Event), + } +} + +func (m *MemoryEventRepository) Save(ctx context.Context, event *domain.Event) error { + m.mu.Lock() + defer m.mu.Unlock() + m.events[event.ID] = event + return nil +} + +func (m *MemoryEventRepository) GetByID(ctx context.Context, eventID string) (*domain.Event, error) { + m.mu.RLock() + defer m.mu.RUnlock() + event, exists := m.events[eventID] + if !exists { + return nil, fmt.Errorf("event with ID %s not found", eventID) + } + return event, nil +} + +func (m *MemoryEventRepository) GetByType(ctx context.Context, eventType domain.EventType) ([]*domain.Event, error) { + m.mu.RLock() + defer m.mu.RUnlock() + var events []*domain.Event + for _, event := range m.events { + if event.Type == eventType { + events = append(events, event) + } + } + return events, nil +} + +func (m *MemoryEventRepository) GetAll(ctx context.Context) ([]*domain.Event, error) { + m.mu.RLock() + defer m.mu.RUnlock() + var events []*domain.Event + for _, event := range m.events { + events = append(events, event) + } + return events, nil +} + +func (m *MemoryEventRepository) Delete(ctx context.Context, eventID string) error { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.events, eventID) + return nil +} + +type MemoryCharacterRepository struct { + characters map[string]*domain.Character + mu sync.RWMutex +} + +func NewMemoryCharacterRepository() *MemoryCharacterRepository { + return &MemoryCharacterRepository{ + characters: make(map[string]*domain.Character), + } +} + +func (m *MemoryCharacterRepository) Save(ctx context.Context, character *domain.Character) error { + m.mu.Lock() + defer m.mu.Unlock() + m.characters[character.ID] = character + return nil +} + +func (m *MemoryCharacterRepository) GetByID(ctx context.Context, characterID string) (*domain.Character, error) { + m.mu.RLock() + defer m.mu.RUnlock() + character, exists := m.characters[characterID] + if !exists { + return nil, fmt.Errorf("character with ID %s not found", characterID) + } + return character, nil +} + +func (m *MemoryCharacterRepository) GetAll(ctx context.Context) ([]*domain.Character, error) { + m.mu.RLock() + defer m.mu.RUnlock() + var characters []*domain.Character + for _, character := range m.characters { + characters = append(characters, character) + } + return characters, nil +} + +func (m *MemoryCharacterRepository) Delete(ctx context.Context, characterID string) error { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.characters, characterID) + return nil +} + +func (m *MemoryCharacterRepository) Update(ctx context.Context, character *domain.Character) error { + m.mu.Lock() + defer m.mu.Unlock() + m.characters[character.ID] = character + return nil +} diff --git a/internal/services/game_engine.go b/internal/services/game_engine.go new file mode 100644 index 0000000..f318cc9 --- /dev/null +++ b/internal/services/game_engine.go @@ -0,0 +1,493 @@ +package services + +import ( + "context" + "fmt" + "time" + + "github.com/TuringProblem/CLIsland/internal/domain" +) + +type GameEngineService struct { + stateRepo domain.StateRepository + eventManager domain.EventManager + characterManager domain.CharacterManager + relationshipManager domain.RelationshipManager + effectProcessor domain.EffectProcessor + requirementChecker domain.RequirementChecker + configProvider domain.ConfigProvider +} + +func NewGameEngineService( + stateRepo domain.StateRepository, + eventManager domain.EventManager, + characterManager domain.CharacterManager, + relationshipManager domain.RelationshipManager, + effectProcessor domain.EffectProcessor, + requirementChecker domain.RequirementChecker, + configProvider domain.ConfigProvider, +) *GameEngineService { + return &GameEngineService{ + stateRepo: stateRepo, + eventManager: eventManager, + characterManager: characterManager, + relationshipManager: relationshipManager, + effectProcessor: effectProcessor, + requirementChecker: requirementChecker, + configProvider: configProvider, + } +} + +func (g *GameEngineService) StartGame(ctx context.Context, playerName string) (*domain.GameState, error) { + config, err := g.configProvider.GetGameConfig(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get game config: %w", err) + } + + player := &domain.Player{ + ID: generateID(), + Name: playerName, + Age: 25, + Personality: domain.Personality{ + Openness: 50.0, + Conscientiousness: 50.0, + Extraversion: 50.0, + Agreeableness: 50.0, + Neuroticism: 50.0, + }, + Relationships: make(map[string]domain.Relationship), + Stats: domain.PlayerStats{ + Popularity: config.StartingPopularity, + Confidence: config.StartingConfidence, + Energy: config.StartingEnergy, + Money: config.StartingMoney, + DayNumber: 1, + }, + Inventory: []domain.Item{}, + CurrentLocation: "villa", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + + characters, err := g.configProvider.GetCharacterConfigs(ctx) + if err != nil { + return nil, fmt.Errorf("failed to load characters: %w", err) + } + + characterMap := make(map[string]*domain.Character) + for i := range characters { + if i >= config.MaxCharacters { + break + } + characters[i].ID = generateID() + characters[i].IsAvailable = true + characters[i].CreatedAt = time.Now() + characterMap[characters[i].ID] = characters[i] + } + + events, err := g.configProvider.GetEventConfigs(ctx) + if err != nil { + return nil, fmt.Errorf("failed to load events: %w", err) + } + + eventMap := make(map[string]*domain.Event) + for i := range events { + events[i].ID = generateID() + events[i].IsActive = true + events[i].CreatedAt = time.Now() + eventMap[events[i].ID] = events[i] + } + + gameState := &domain.GameState{ + Player: player, + Characters: characterMap, + Events: eventMap, + CurrentEvent: nil, + GameDay: 1, + IsGameOver: false, + Winner: "", + } + + if err := g.stateRepo.Save(ctx, gameState); err != nil { + return nil, fmt.Errorf("failed to save initial game state: %w", err) + } + + return gameState, nil +} + +func (g *GameEngineService) ProcessChoice(ctx context.Context, choiceID string) (*domain.GameState, error) { + gameState, err := g.stateRepo.Load(ctx) + if err != nil { + return nil, fmt.Errorf("failed to load game state: %w", err) + } + + if gameState.CurrentEvent == nil { + return nil, fmt.Errorf("no current event to process choice for") + } + + var selectedChoice *domain.Choice + for _, choice := range gameState.CurrentEvent.Choices { + if choice.ID == choiceID { + selectedChoice = &choice + break + } + } + + if selectedChoice == nil { + return nil, fmt.Errorf("choice with ID %s not found", choiceID) + } + + if len(selectedChoice.Requirements) > 0 { + requirements := make([]*domain.Requirement, len(selectedChoice.Requirements)) + for i := range selectedChoice.Requirements { + requirements[i] = &selectedChoice.Requirements[i] + } + meetsRequirements, err := g.requirementChecker.CheckRequirements(ctx, requirements, gameState) + if err != nil { + return nil, fmt.Errorf("failed to check requirements: %w", err) + } + if !meetsRequirements { + return nil, fmt.Errorf("requirements not met for choice %s", choiceID) + } + } + + effects := make([]*domain.Effect, len(selectedChoice.Effects)) + for i := range selectedChoice.Effects { + effects[i] = &selectedChoice.Effects[i] + } + if err := g.effectProcessor.ApplyEffects(ctx, effects, gameState); err != nil { + return nil, fmt.Errorf("failed to apply choice effects: %w", err) + } + + gameState.Player.UpdatedAt = time.Now() + + if err := g.stateRepo.Save(ctx, gameState); err != nil { + return nil, fmt.Errorf("failed to save game state: %w", err) + } + + return gameState, nil +} + +func (g *GameEngineService) AdvanceDay(ctx context.Context) (*domain.GameState, error) { + gameState, err := g.stateRepo.Load(ctx) + if err != nil { + return nil, fmt.Errorf("failed to load game state: %w", err) + } + + config, err := g.configProvider.GetGameConfig(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get game config: %w", err) + } + + if gameState.GameDay >= config.MaxDays { + gameState.IsGameOver = true + gameState.Winner = determineWinner(gameState) + return gameState, nil + } + + gameState.GameDay++ + gameState.Player.Stats.DayNumber = gameState.GameDay + + gameState.Player.Stats.Energy = min(100.0, gameState.Player.Stats.Energy+20.0) + + for _, character := range gameState.Characters { + if character.IsAvailable { + character.Stats.Energy = min(100.0, character.Stats.Energy+15.0) + character.Stats.Stress = max(0.0, character.Stats.Stress-5.0) + } + } + + newEvent, err := g.generateDailyEvent(ctx, gameState) + if err != nil { + return nil, fmt.Errorf("failed to generate daily event: %w", err) + } + gameState.CurrentEvent = newEvent + + if err := g.stateRepo.Save(ctx, gameState); err != nil { + return nil, fmt.Errorf("failed to save game state: %w", err) + } + + return gameState, nil +} + +func (g *GameEngineService) EndGame(ctx context.Context) error { + gameState, err := g.stateRepo.Load(ctx) + if err != nil { + return fmt.Errorf("failed to load game state: %w", err) + } + + gameState.IsGameOver = true + gameState.Winner = determineWinner(gameState) + + return g.stateRepo.Save(ctx, gameState) +} + +func (g *GameEngineService) GetCurrentState(ctx context.Context) (*domain.GameState, error) { + return g.stateRepo.Load(ctx) +} + +func (g *GameEngineService) SaveGame(ctx context.Context) error { + gameState, err := g.stateRepo.Load(ctx) + if err != nil { + return fmt.Errorf("failed to load game state: %w", err) + } + + return g.stateRepo.Save(ctx, gameState) +} + +func (g *GameEngineService) LoadGame(ctx context.Context) (*domain.GameState, error) { + return g.stateRepo.Load(ctx) +} + +func (g *GameEngineService) GetAvailableEvents(ctx context.Context) ([]*domain.Event, error) { + gameState, err := g.stateRepo.Load(ctx) + if err != nil { + return nil, fmt.Errorf("failed to load game state: %w", err) + } + + var availableEvents []*domain.Event + for _, event := range gameState.Events { + if event.IsActive { + if len(event.Requirements) > 0 { + requirements := make([]*domain.Requirement, len(event.Requirements)) + for i := range event.Requirements { + requirements[i] = &event.Requirements[i] + } + meetsRequirements, err := g.requirementChecker.CheckRequirements(ctx, requirements, gameState) + if err != nil { + continue + } + if meetsRequirements { + availableEvents = append(availableEvents, event) + } + } else { + availableEvents = append(availableEvents, event) + } + } + } + + return availableEvents, nil +} + +func (g *GameEngineService) TriggerEvent(ctx context.Context, eventID string) (*domain.GameState, error) { + gameState, err := g.stateRepo.Load(ctx) + if err != nil { + return nil, fmt.Errorf("failed to load game state: %w", err) + } + + event, exists := gameState.Events[eventID] + if !exists { + return nil, fmt.Errorf("event with ID %s not found", eventID) + } + + if !event.IsActive { + return nil, fmt.Errorf("event %s is not active", eventID) + } + + if len(event.Requirements) > 0 { + requirements := make([]*domain.Requirement, len(event.Requirements)) + for i := range event.Requirements { + requirements[i] = &event.Requirements[i] + } + meetsRequirements, err := g.requirementChecker.CheckRequirements(ctx, requirements, gameState) + if err != nil { + return nil, fmt.Errorf("failed to check event requirements: %w", err) + } + if !meetsRequirements { + return nil, fmt.Errorf("requirements not met for event %s", eventID) + } + } + + gameState.CurrentEvent = event + + if err := g.stateRepo.Save(ctx, gameState); err != nil { + return nil, fmt.Errorf("failed to save game state: %w", err) + } + + return gameState, nil +} + +func (g *GameEngineService) GetAvailableCharacters(ctx context.Context) ([]*domain.Character, error) { + gameState, err := g.stateRepo.Load(ctx) + if err != nil { + return nil, fmt.Errorf("failed to load game state: %w", err) + } + + var availableCharacters []*domain.Character + for _, character := range gameState.Characters { + if character.IsAvailable { + availableCharacters = append(availableCharacters, character) + } + } + + return availableCharacters, nil +} + +func (g *GameEngineService) InteractWithCharacter(ctx context.Context, characterID string, interactionType domain.InteractionType) (*domain.GameState, error) { + gameState, err := g.stateRepo.Load(ctx) + if err != nil { + return nil, fmt.Errorf("failed to load game state: %w", err) + } + + character, exists := gameState.Characters[characterID] + if !exists { + return nil, fmt.Errorf("character with ID %s not found", characterID) + } + + if !character.IsAvailable { + return nil, fmt.Errorf("character %s is not available", characterID) + } + + interaction := &domain.Interaction{ + Type: interactionType, + Description: generateInteractionDescription(interactionType, character.Name), + Effects: generateInteractionEffects(interactionType), + Timestamp: time.Now(), + } + + if err := g.relationshipManager.AddInteraction(ctx, gameState.Player.ID, characterID, interaction); err != nil { + return nil, fmt.Errorf("failed to add interaction: %w", err) + } + + effects := make([]*domain.Effect, len(interaction.Effects)) + for i := range interaction.Effects { + effects[i] = &interaction.Effects[i] + } + if err := g.effectProcessor.ApplyEffects(ctx, effects, gameState); err != nil { + return nil, fmt.Errorf("failed to apply interaction effects: %w", err) + } + + if err := g.stateRepo.Save(ctx, gameState); err != nil { + return nil, fmt.Errorf("failed to save game state: %w", err) + } + + return gameState, nil +} + +func generateID() string { + return fmt.Sprintf("%d", time.Now().UnixNano()) +} + +func determineWinner(gameState *domain.GameState) string { + var winner string + maxPopularity := -1.0 + + for characterID, relationship := range gameState.Player.Relationships { + if relationship.Affection > maxPopularity { + maxPopularity = relationship.Affection + winner = characterID + } + } + + return winner +} + +func (g *GameEngineService) generateDailyEvent(ctx context.Context, gameState *domain.GameState) (*domain.Event, error) { + config, err := g.configProvider.GetGameConfig(ctx) + if err != nil { + return nil, err + } + + var eventType domain.EventType + switch { + case gameState.GameDay == config.EliminationDay: + eventType = domain.EventTypeElimination + case gameState.GameDay == config.FinaleDay: + eventType = domain.EventTypeRecoupling + case gameState.GameDay%3 == 0: + eventType = domain.EventTypeChallenge + case gameState.GameDay%2 == 0: + eventType = domain.EventTypeDate + default: + eventType = domain.EventTypeDrama + } + + for _, event := range gameState.Events { + if event.Type == eventType && event.IsActive { + return event, nil + } + } + + for _, event := range gameState.Events { + if event.IsActive { + return event, nil + } + } + + return nil, fmt.Errorf("no available events found") +} + +func generateInteractionDescription(interactionType domain.InteractionType, characterName string) string { + switch interactionType { + case domain.InteractionTypeConversation: + return fmt.Sprintf("You had a deep conversation with %s", characterName) + case domain.InteractionTypeDate: + return fmt.Sprintf("You went on a romantic date with %s", characterName) + case domain.InteractionTypeChallenge: + return fmt.Sprintf("You participated in a challenge with %s", characterName) + case domain.InteractionTypeGift: + return fmt.Sprintf("You gave a thoughtful gift to %s", characterName) + case domain.InteractionTypeArgument: + return fmt.Sprintf("You had a heated argument with %s", characterName) + default: + return fmt.Sprintf("You interacted with %s", characterName) + } +} + +func generateInteractionEffects(interactionType domain.InteractionType) []domain.Effect { + var effects []domain.Effect + + switch interactionType { + case domain.InteractionTypeConversation: + effects = append(effects, domain.Effect{ + Type: domain.EffectTypeAffection, + Target: "player", + Value: 5.0, + Description: "Deep conversation increased affection", + }) + case domain.InteractionTypeDate: + effects = append(effects, domain.Effect{ + Type: domain.EffectTypeAffection, + Target: "player", + Value: 15.0, + Description: "Romantic date significantly increased affection", + }) + case domain.InteractionTypeChallenge: + effects = append(effects, domain.Effect{ + Type: domain.EffectTypeTrust, + Target: "player", + Value: 10.0, + Description: "Team challenge built trust", + }) + case domain.InteractionTypeGift: + effects = append(effects, domain.Effect{ + Type: domain.EffectTypeAffection, + Target: "player", + Value: 8.0, + Description: "Thoughtful gift increased affection", + }) + case domain.InteractionTypeArgument: + effects = append(effects, domain.Effect{ + Type: domain.EffectTypeAffection, + Target: "player", + Value: -10.0, + Description: "Argument decreased affection", + }) + } + + return effects +} + +func min(a, b float64) float64 { + if a < b { + return a + } + return b +} + +func max(a, b float64) float64 { + if a > b { + return a + } + return b +} diff --git a/internal/services/game_engine_test.go b/internal/services/game_engine_test.go new file mode 100644 index 0000000..16838f2 --- /dev/null +++ b/internal/services/game_engine_test.go @@ -0,0 +1,150 @@ +package services + +import ( + "context" + "testing" + + "github.com/TuringProblem/CLIsland/internal/repositories" +) + +func TestGameEngine_StartGame(t *testing.T) { + stateRepo := repositories.NewMemoryStateRepository() + eventManager := NewStubEventManager() + characterManager := NewStubCharacterManager() + relationshipManager := NewStubRelationshipManager() + effectProcessor := NewStubEffectProcessor() + requirementChecker := NewStubRequirementChecker() + configProvider := NewStubConfigProvider() + + gameEngine := NewGameEngineService( + stateRepo, + eventManager, + characterManager, + relationshipManager, + effectProcessor, + requirementChecker, + configProvider, + ) + + ctx := context.Background() + + gameState, err := gameEngine.StartGame(ctx, "TestPlayer") + + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if gameState == nil { + t.Fatal("Expected game state, got nil") + } + + if gameState.Player == nil { + t.Fatal("Expected player, got nil") + } + + if gameState.Player.Name != "TestPlayer" { + t.Errorf("Expected player name 'TestPlayer', got '%s'", gameState.Player.Name) + } + + if gameState.GameDay != 1 { + t.Errorf("Expected game day 1, got %d", gameState.GameDay) + } + + if gameState.IsGameOver { + t.Error("Expected game not to be over") + } + + if len(gameState.Characters) == 0 { + t.Error("Expected characters to be loaded") + } + + if len(gameState.Events) == 0 { + t.Error("Expected events to be loaded") + } +} + +func TestGameEngine_AdvanceDay(t *testing.T) { + stateRepo := repositories.NewMemoryStateRepository() + eventManager := NewStubEventManager() + characterManager := NewStubCharacterManager() + relationshipManager := NewStubRelationshipManager() + effectProcessor := NewStubEffectProcessor() + requirementChecker := NewStubRequirementChecker() + configProvider := NewStubConfigProvider() + + gameEngine := NewGameEngineService( + stateRepo, + eventManager, + characterManager, + relationshipManager, + effectProcessor, + requirementChecker, + configProvider, + ) + + ctx := context.Background() + + _, err := gameEngine.StartGame(ctx, "TestPlayer") + if err != nil { + t.Fatalf("Failed to start game: %v", err) + } + + gameState, err := gameEngine.AdvanceDay(ctx) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if gameState.GameDay != 2 { + t.Errorf("Expected game day 2, got %d", gameState.GameDay) + } + + if gameState.Player.Stats.DayNumber != 2 { + t.Errorf("Expected player day number 2, got %d", gameState.Player.Stats.DayNumber) + } + + if gameState.Player.Stats.Energy < 100.0 { + t.Errorf("Expected energy to be regenerated, got %.1f", gameState.Player.Stats.Energy) + } +} + +func TestGameEngine_GetAvailableCharacters(t *testing.T) { + stateRepo := repositories.NewMemoryStateRepository() + eventManager := NewStubEventManager() + characterManager := NewStubCharacterManager() + relationshipManager := NewStubRelationshipManager() + effectProcessor := NewStubEffectProcessor() + requirementChecker := NewStubRequirementChecker() + configProvider := NewStubConfigProvider() + + gameEngine := NewGameEngineService( + stateRepo, + eventManager, + characterManager, + relationshipManager, + effectProcessor, + requirementChecker, + configProvider, + ) + + ctx := context.Background() + + _, err := gameEngine.StartGame(ctx, "TestPlayer") + if err != nil { + t.Fatalf("Failed to start game: %v", err) + } + + characters, err := gameEngine.GetAvailableCharacters(ctx) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if len(characters) == 0 { + t.Error("Expected available characters, got none") + } + + for _, character := range characters { + if !character.IsAvailable { + t.Errorf("Expected character %s to be available", character.Name) + } + } +} diff --git a/internal/services/stub_services.go b/internal/services/stub_services.go new file mode 100644 index 0000000..7804ebf --- /dev/null +++ b/internal/services/stub_services.go @@ -0,0 +1,464 @@ +package services + +import ( + "context" + "fmt" + "math" + "time" + + "github.com/TuringProblem/CLIsland/internal/domain" +) + +type StubEventManager struct{} + +func NewStubEventManager() *StubEventManager { + return &StubEventManager{} +} + +func (s *StubEventManager) CreateEvent(ctx context.Context, event *domain.Event) error { + return nil +} + +func (s *StubEventManager) GetEvent(ctx context.Context, eventID string) (*domain.Event, error) { + return nil, fmt.Errorf("not implemented") +} + +func (s *StubEventManager) UpdateEvent(ctx context.Context, event *domain.Event) error { + return nil +} + +func (s *StubEventManager) DeleteEvent(ctx context.Context, eventID string) error { + return nil +} + +func (s *StubEventManager) ListEvents(ctx context.Context, eventType domain.EventType) ([]*domain.Event, error) { + return []*domain.Event{}, nil +} + +func (s *StubEventManager) ValidateEvent(ctx context.Context, event *domain.Event) error { + return nil +} + +type StubCharacterManager struct{} + +func NewStubCharacterManager() *StubCharacterManager { + return &StubCharacterManager{} +} + +func (s *StubCharacterManager) CreateCharacter(ctx context.Context, character *domain.Character) error { + return nil +} + +func (s *StubCharacterManager) GetCharacter(ctx context.Context, characterID string) (*domain.Character, error) { + return nil, fmt.Errorf("not implemented") +} + +func (s *StubCharacterManager) UpdateCharacter(ctx context.Context, character *domain.Character) error { + return nil +} + +func (s *StubCharacterManager) DeleteCharacter(ctx context.Context, characterID string) error { + return nil +} + +func (s *StubCharacterManager) ListCharacters(ctx context.Context) ([]*domain.Character, error) { + return []*domain.Character{}, nil +} + +func (s *StubCharacterManager) UpdateCharacterStats(ctx context.Context, characterID string, stats domain.CharacterStats) error { + return nil +} + +type StubRelationshipManager struct { + relationships map[string]map[string]*domain.Relationship +} + +func NewStubRelationshipManager() *StubRelationshipManager { + return &StubRelationshipManager{ + relationships: make(map[string]map[string]*domain.Relationship), + } +} + +func (s *StubRelationshipManager) GetRelationship(ctx context.Context, playerID, characterID string) (*domain.Relationship, error) { + if playerRelationships, exists := s.relationships[playerID]; exists { + if relationship, exists := playerRelationships[characterID]; exists { + return relationship, nil + } + } + + relationship := &domain.Relationship{ + CharacterID: characterID, + Affection: 0.0, + Trust: 0.0, + Compatibility: 0.0, + Status: domain.RelationshipStatusSingle, + History: []domain.Interaction{}, + UpdatedAt: time.Now(), + } + + if s.relationships[playerID] == nil { + s.relationships[playerID] = make(map[string]*domain.Relationship) + } + s.relationships[playerID][characterID] = relationship + + return relationship, nil +} + +func (s *StubRelationshipManager) UpdateRelationship(ctx context.Context, playerID, characterID string, relationship *domain.Relationship) error { + if s.relationships[playerID] == nil { + s.relationships[playerID] = make(map[string]*domain.Relationship) + } + s.relationships[playerID][characterID] = relationship + return nil +} + +func (s *StubRelationshipManager) AddInteraction(ctx context.Context, playerID, characterID string, interaction *domain.Interaction) error { + relationship, err := s.GetRelationship(ctx, playerID, characterID) + if err != nil { + return err + } + + relationship.History = append(relationship.History, *interaction) + relationship.UpdatedAt = time.Now() + + for _, effect := range interaction.Effects { + switch effect.Type { + case domain.EffectTypeAffection: + relationship.Affection = math.Max(-100, math.Min(100, relationship.Affection+effect.Value)) + case domain.EffectTypeTrust: + relationship.Trust = math.Max(0, math.Min(100, relationship.Trust+effect.Value)) + } + } + + return s.UpdateRelationship(ctx, playerID, characterID, relationship) +} + +func (s *StubRelationshipManager) CalculateCompatibility(ctx context.Context, player *domain.Player, character *domain.Character) (float64, error) { + opennessDiff := math.Abs(player.Personality.Openness - character.Personality.Openness) + extraversionDiff := math.Abs(player.Personality.Extraversion - character.Personality.Extraversion) + agreeablenessDiff := math.Abs(player.Personality.Agreeableness - character.Personality.Agreeableness) + + totalDiff := opennessDiff + extraversionDiff + agreeablenessDiff + compatibility := math.Max(0, 100-totalDiff) + + return compatibility, nil +} + +func (s *StubRelationshipManager) GetRelationshipHistory(ctx context.Context, playerID, characterID string) ([]*domain.Interaction, error) { + relationship, err := s.GetRelationship(ctx, playerID, characterID) + if err != nil { + return nil, err + } + + interactions := make([]*domain.Interaction, len(relationship.History)) + for i := range relationship.History { + interactions[i] = &relationship.History[i] + } + + return interactions, nil +} + +type StubEffectProcessor struct{} + +func NewStubEffectProcessor() *StubEffectProcessor { + return &StubEffectProcessor{} +} + +func (s *StubEffectProcessor) ApplyEffect(ctx context.Context, effect *domain.Effect, gameState *domain.GameState) error { + switch effect.Type { + case domain.EffectTypeAffection: + if relationship, exists := gameState.Player.Relationships[effect.Target]; exists { + relationship.Affection = math.Max(-100, math.Min(100, relationship.Affection+effect.Value)) + gameState.Player.Relationships[effect.Target] = relationship + } + case domain.EffectTypeTrust: + if relationship, exists := gameState.Player.Relationships[effect.Target]; exists { + relationship.Trust = math.Max(0, math.Min(100, relationship.Trust+effect.Value)) + gameState.Player.Relationships[effect.Target] = relationship + } + case domain.EffectTypePopularity: + gameState.Player.Stats.Popularity = math.Max(0, math.Min(100, gameState.Player.Stats.Popularity+effect.Value)) + case domain.EffectTypeConfidence: + gameState.Player.Stats.Confidence = math.Max(0, math.Min(100, gameState.Player.Stats.Confidence+effect.Value)) + case domain.EffectTypeEnergy: + gameState.Player.Stats.Energy = math.Max(0, math.Min(100, gameState.Player.Stats.Energy+effect.Value)) + case domain.EffectTypeMoney: + gameState.Player.Stats.Money += int(effect.Value) + } + return nil +} + +func (s *StubEffectProcessor) ApplyEffects(ctx context.Context, effects []*domain.Effect, gameState *domain.GameState) error { + for _, effect := range effects { + if err := s.ApplyEffect(ctx, effect, gameState); err != nil { + return err + } + } + return nil +} + +func (s *StubEffectProcessor) ValidateEffect(ctx context.Context, effect *domain.Effect) error { + return nil +} + +func (s *StubEffectProcessor) ReverseEffect(ctx context.Context, effect *domain.Effect, gameState *domain.GameState) error { + reversedEffect := &domain.Effect{ + Type: effect.Type, + Target: effect.Target, + Value: -effect.Value, + Description: "Reversed: " + effect.Description, + } + return s.ApplyEffect(ctx, reversedEffect, gameState) +} + +type StubRequirementChecker struct{} + +func NewStubRequirementChecker() *StubRequirementChecker { + return &StubRequirementChecker{} +} + +func (s *StubRequirementChecker) CheckRequirement(ctx context.Context, requirement *domain.Requirement, gameState *domain.GameState) (bool, error) { + switch requirement.Type { + case domain.RequirementTypeAffection: + if relationship, exists := gameState.Player.Relationships[requirement.Target]; exists { + return s.compareValues(relationship.Affection, requirement.Value, requirement.Operator), nil + } + return false, nil + case domain.RequirementTypeTrust: + if relationship, exists := gameState.Player.Relationships[requirement.Target]; exists { + return s.compareValues(relationship.Trust, requirement.Value, requirement.Operator), nil + } + return false, nil + case domain.RequirementTypePopularity: + return s.compareValues(gameState.Player.Stats.Popularity, requirement.Value, requirement.Operator), nil + case domain.RequirementTypeConfidence: + return s.compareValues(gameState.Player.Stats.Confidence, requirement.Value, requirement.Operator), nil + case domain.RequirementTypeEnergy: + return s.compareValues(gameState.Player.Stats.Energy, requirement.Value, requirement.Operator), nil + case domain.RequirementTypeMoney: + return s.compareValues(float64(gameState.Player.Stats.Money), requirement.Value, requirement.Operator), nil + case domain.RequirementTypeDayNumber: + return s.compareValues(float64(gameState.Player.Stats.DayNumber), requirement.Value, requirement.Operator), nil + default: + return false, fmt.Errorf("unknown requirement type: %s", requirement.Type) + } +} + +func (s *StubRequirementChecker) CheckRequirements(ctx context.Context, requirements []*domain.Requirement, gameState *domain.GameState) (bool, error) { + for _, requirement := range requirements { + met, err := s.CheckRequirement(ctx, requirement, gameState) + if err != nil { + return false, err + } + if !met { + return false, nil + } + } + return true, nil +} + +func (s *StubRequirementChecker) GetFailedRequirements(ctx context.Context, requirements []*domain.Requirement, gameState *domain.GameState) ([]*domain.Requirement, error) { + var failed []*domain.Requirement + for _, requirement := range requirements { + met, err := s.CheckRequirement(ctx, requirement, gameState) + if err != nil { + return nil, err + } + if !met { + failed = append(failed, requirement) + } + } + return failed, nil +} + +func (s *StubRequirementChecker) compareValues(actual, expected float64, operator string) bool { + switch operator { + case "eq": + return actual == expected + case "gt": + return actual > expected + case "lt": + return actual < expected + case "gte": + return actual >= expected + case "lte": + return actual <= expected + default: + return false + } +} + +type StubConfigProvider struct{} + +func NewStubConfigProvider() *StubConfigProvider { + return &StubConfigProvider{} +} + +func (s *StubConfigProvider) GetGameConfig(ctx context.Context) (*domain.GameConfig, error) { + return &domain.GameConfig{ + MaxDays: 30, + StartingMoney: 1000, + StartingEnergy: 100.0, + StartingConfidence: 50.0, + StartingPopularity: 25.0, + MaxCharacters: 6, + EliminationDay: 15, + FinaleDay: 30, + }, nil +} + +func (s *StubConfigProvider) GetEventConfigs(ctx context.Context) ([]*domain.Event, error) { + return []*domain.Event{ + { + ID: "event_1", + Title: "Welcome to the Villa", + Description: "You arrive at the Love Island villa and meet your fellow contestants.", + Type: domain.EventTypeDrama, + Choices: []domain.Choice{ + { + ID: "choice_1", + Text: "Be confident and introduce yourself", + Description: "Show your personality and make a good first impression", + Effects: []domain.Effect{ + {Type: domain.EffectTypeConfidence, Target: "player", Value: 10.0, Description: "Confident introduction"}, + {Type: domain.EffectTypePopularity, Target: "player", Value: 5.0, Description: "Good first impression"}, + }, + }, + { + ID: "choice_2", + Text: "Stay quiet and observe", + Description: "Take time to understand the dynamics before getting involved", + Effects: []domain.Effect{ + {Type: domain.EffectTypeConfidence, Target: "player", Value: -5.0, Description: "Quiet observation"}, + }, + }, + }, + IsActive: true, + }, + { + ID: "event_2", + Title: "First Challenge", + Description: "The villa's first challenge tests your teamwork and communication.", + Type: domain.EventTypeChallenge, + Choices: []domain.Choice{ + { + ID: "choice_3", + Text: "Take charge and lead the team", + Description: "Show leadership qualities", + Effects: []domain.Effect{ + {Type: domain.EffectTypeConfidence, Target: "player", Value: 15.0, Description: "Leadership shown"}, + {Type: domain.EffectTypeEnergy, Target: "player", Value: -10.0, Description: "Leadership effort"}, + }, + }, + { + ID: "choice_4", + Text: "Support your partner", + Description: "Work together and build trust", + Effects: []domain.Effect{ + {Type: domain.EffectTypeTrust, Target: "partner", Value: 10.0, Description: "Teamwork builds trust"}, + }, + }, + }, + IsActive: true, + }, + }, nil +} + +func (s *StubConfigProvider) GetCharacterConfigs(ctx context.Context) ([]*domain.Character, error) { + return []*domain.Character{ + { + ID: "char_1", + Name: "Emma", + Age: 24, + Personality: domain.Personality{ + Openness: 70.0, + Conscientiousness: 60.0, + Extraversion: 80.0, + Agreeableness: 75.0, + Neuroticism: 30.0, + }, + Appearance: domain.Appearance{ + Height: 165, + Build: "Athletic", + HairColor: "Blonde", + EyeColor: "Blue", + Style: "Casual chic", + }, + Stats: domain.CharacterStats{ + Popularity: 60.0, + Energy: 85.0, + Stress: 20.0, + }, + IsAvailable: true, + }, + { + ID: "char_2", + Name: "James", + Age: 26, + Personality: domain.Personality{ + Openness: 50.0, + Conscientiousness: 80.0, + Extraversion: 60.0, + Agreeableness: 65.0, + Neuroticism: 40.0, + }, + Appearance: domain.Appearance{ + Height: 180, + Build: "Muscular", + HairColor: "Brown", + EyeColor: "Green", + Style: "Smart casual", + }, + Stats: domain.CharacterStats{ + Popularity: 55.0, + Energy: 75.0, + Stress: 30.0, + }, + IsAvailable: true, + }, + { + ID: "char_3", + Name: "Sophie", + Age: 23, + Personality: domain.Personality{ + Openness: 85.0, + Conscientiousness: 40.0, + Extraversion: 90.0, + Agreeableness: 70.0, + Neuroticism: 50.0, + }, + Appearance: domain.Appearance{ + Height: 170, + Build: "Slim", + HairColor: "Red", + EyeColor: "Hazel", + Style: "Bohemian", + }, + Stats: domain.CharacterStats{ + Popularity: 70.0, + Energy: 90.0, + Stress: 15.0, + }, + IsAvailable: true, + }, + }, nil +} + +func (s *StubConfigProvider) GetItemConfigs(ctx context.Context) ([]*domain.Item, error) { + return []*domain.Item{ + { + ID: "item_1", + Name: "Rose", + Description: "A beautiful red rose", + Type: domain.ItemTypeGift, + Value: 50, + }, + { + ID: "item_2", + Name: "Chocolate", + Description: "Delicious chocolate box", + Type: domain.ItemTypeGift, + Value: 30, + }, + }, nil +} diff --git a/scripts/demo.sh b/scripts/demo.sh new file mode 100755 index 0000000..727f6c4 --- /dev/null +++ b/scripts/demo.sh @@ -0,0 +1,64 @@ +#!/bin/bash + +echo "🏝️ CLIsland Demo Script 🏝️" +echo "================================" + +# Check if Go is installed +if ! command -v go &> /dev/null; then + echo "❌ Go is not installed. Please install Go 1.20+ first." + exit 1 +fi + +echo "✅ Go is installed: $(go version)" + +# Build the game +echo "" +echo "🔨 Building CLIsland..." +if go build -o clisland ./cmd/main.go; then + echo "✅ Build successful!" +else + echo "❌ Build failed!" + exit 1 +fi + +# Run tests +echo "" +echo "🧪 Running tests..." +if go test ./internal/services/ -v; then + echo "✅ Tests passed!" +else + echo "❌ Tests failed!" + exit 1 +fi + +# Check if binary exists +if [ -f "./clisland" ]; then + echo "" + echo "🎮 CLIsland is ready to play!" + echo "" + echo "To start the game, run:" + echo " ./clisland" + echo "" + echo "Or use make commands:" + echo " make build # Build the game" + echo " make test # Run tests" + echo " make lint # Run linter" + echo " make coverage # Run tests with coverage" + echo "" + echo "Game features:" + echo " - 3 characters with unique personalities" + echo " - Story events with multiple choices" + echo " - Relationship building system" + echo " - 30-day game cycle" + echo " - Stats management (energy, confidence, popularity, money)" + echo "" + echo "Would you like to start the game now? (y/n)" + read -r response + if [[ "$response" =~ ^[Yy]$ ]]; then + echo "Starting CLIsland..." + ./clisland + fi +else + echo "❌ Binary not found after build!" + exit 1 +fi \ No newline at end of file diff --git a/tests/run_tests.sh b/tests/run_tests.sh new file mode 100755 index 0000000..9dbc31d --- /dev/null +++ b/tests/run_tests.sh @@ -0,0 +1,88 @@ +#!/bin/bash + +# Test runner script for CLIsland +# Usage: ./tests/run_tests.sh [unit|integration|e2e|all] + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Function to print colored output +print_status() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +print_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +print_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Function to run tests +run_tests() { + local test_type=$1 + local test_path=$2 + + print_status "Running $test_type tests..." + + if [ -d "$test_path" ]; then + cd "$test_path" + if go test -v ./...; then + print_success "$test_type tests passed" + else + print_error "$test_type tests failed" + return 1 + fi + cd - > /dev/null + else + print_warning "No $test_type tests found at $test_path" + fi +} + +# Function to run all tests +run_all_tests() { + print_status "Running all tests..." + + # Run unit tests + run_tests "unit" "tests/unit" + + # Run integration tests + run_tests "integration" "tests/integration" + + # Run e2e tests + run_tests "e2e" "tests/e2e" + + print_success "All tests completed" +} + +# Main script logic +case "${1:-all}" in + "unit") + run_tests "unit" "tests/unit" + ;; + "integration") + run_tests "integration" "tests/integration" + ;; + "e2e") + run_tests "e2e" "tests/e2e" + ;; + "all") + run_all_tests + ;; + *) + print_error "Unknown test type: $1" + echo "Usage: $0 [unit|integration|e2e|all]" + exit 1 + ;; +esac \ No newline at end of file diff --git a/tests/test_config.go b/tests/test_config.go new file mode 100644 index 0000000..d617399 --- /dev/null +++ b/tests/test_config.go @@ -0,0 +1,46 @@ +package tests + +import ( + "os" + "testing" +) + +// TestConfig holds configuration for tests +type TestConfig struct { + TestDataPath string + Verbose bool + RunSlowTests bool +} + +// GetTestConfig returns test configuration based on environment +func GetTestConfig() TestConfig { + return TestConfig{ + TestDataPath: getEnvOrDefault("TEST_DATA_PATH", "data"), + Verbose: getEnvOrDefault("TEST_VERBOSE", "false") == "true", + RunSlowTests: getEnvOrDefault("TEST_RUN_SLOW", "false") == "true", + } +} + +// getEnvOrDefault gets environment variable or returns default +func getEnvOrDefault(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + +// SkipIfSlowTest skips the test if slow tests are disabled +func SkipIfSlowTest(t *testing.T) { + config := GetTestConfig() + if !config.RunSlowTests { + t.Skip("Skipping slow test. Set TEST_RUN_SLOW=true to run.") + } +} + +// SkipIfNoTestData skips the test if test data is not available +func SkipIfNoTestData(t *testing.T) { + config := GetTestConfig() + if _, err := os.Stat(config.TestDataPath); os.IsNotExist(err) { + t.Skipf("Test data not found at %s. Set TEST_DATA_PATH to specify location.", config.TestDataPath) + } +} diff --git a/tests/unit/utils_test.go b/tests/unit/utils_test.go new file mode 100644 index 0000000..634739c --- /dev/null +++ b/tests/unit/utils_test.go @@ -0,0 +1,143 @@ +package tests + +import ( + "testing" + + "github.com/TuringProblem/CLIsland/utils" +) + +func TestGenerateCharacters(t *testing.T) { + maleCharacters := utils.GenerateCharacters([]string{}, "male") + if len(maleCharacters) != 11 { + t.Errorf("Expected 11 characters for male player, got %d", len(maleCharacters)) + } + + femaleCharacters := utils.GenerateCharacters([]string{}, "female") + if len(femaleCharacters) != 11 { + t.Errorf("Expected 11 characters for female player, got %d", len(femaleCharacters)) + } + + for i, name := range maleCharacters { + if name == "" { + t.Errorf("Character %d has empty name", i) + } + } + + for i, name := range femaleCharacters { + if name == "" { + t.Errorf("Character %d has empty name", i) + } + } +} + +func TestGenerateRandomNameFromFile(t *testing.T) { + maleName := utils.GenerateRandomNameFromFile("male") + if maleName == "" { + t.Error("Generated male name is empty") + } + + femaleName := utils.GenerateRandomNameFromFile("female") + if femaleName == "" { + t.Error("Generated female name is empty") + } +} + +func TestGeneratePerson(t *testing.T) { + malePerson := utils.GeneratePerson("male") + if malePerson.Name == "" { + t.Error("Generated male person has empty name") + } + if malePerson.Age < 18 || malePerson.Age > 35 { + t.Errorf("Generated male person age %d is outside valid range (18-35)", malePerson.Age) + } + if malePerson.Sex != "male" { + t.Errorf("Generated male person has wrong sex: %s", malePerson.Sex) + } + if malePerson.Height < 65 || malePerson.Height > 78 { + t.Errorf("Generated male person height %.1f is outside valid range (65-78 inches)", malePerson.Height) + } + if len(malePerson.GetInterests()) < 3 || len(malePerson.GetInterests()) > 6 { + t.Errorf("Generated male person has %d interests, expected 3-6", len(malePerson.GetInterests())) + } + + femalePerson := utils.GeneratePerson("female") + if femalePerson.Name == "" { + t.Error("Generated female person has empty name") + } + if femalePerson.Age < 18 || femalePerson.Age > 35 { + t.Errorf("Generated female person age %d is outside valid range (18-35)", femalePerson.Age) + } + if femalePerson.Sex != "female" { + t.Errorf("Generated female person has wrong sex: %s", femalePerson.Sex) + } + if femalePerson.Height < 60 || femalePerson.Height > 72 { + t.Errorf("Generated female person height %.1f is outside valid range (60-72 inches)", femalePerson.Height) + } + if len(femalePerson.GetInterests()) < 3 || len(femalePerson.GetInterests()) > 6 { + t.Errorf("Generated female person has %d interests, expected 3-6", len(femalePerson.GetInterests())) + } +} + +func TestGeneratePersonList(t *testing.T) { + malePlayerPeople := utils.GeneratePersonList("male") + if len(malePlayerPeople) != 11 { + t.Errorf("Expected 11 people for male player, got %d", len(malePlayerPeople)) + } + + femalePlayerPeople := utils.GeneratePersonList("female") + if len(femalePlayerPeople) != 11 { + t.Errorf("Expected 11 people for female player, got %d", len(femalePlayerPeople)) + } + + for i, person := range malePlayerPeople { + if person.Name == "" { + t.Errorf("Person %d has empty name", i) + } + if person.Age < 18 || person.Age > 35 { + t.Errorf("Person %d has invalid age: %d", i, person.Age) + } + if len(person.GetInterests()) < 3 || len(person.GetInterests()) > 6 { + t.Errorf("Person %d has invalid number of interests: %d", i, len(person.GetInterests())) + } + } +} + +func TestPersonMethods(t *testing.T) { + person := utils.GeneratePerson("male") + + heightFeet := person.GetHeightInFeet() + if heightFeet == "" { + t.Error("GetHeightInFeet returned empty string") + } + + heightCm := person.GetHeightInCm() + if heightCm <= 0 { + t.Error("GetHeightInCm returned invalid value") + } + + weightKg := person.GetWeightInKg() + if weightKg <= 0 { + t.Error("GetWeightInKg returned invalid value") + } + + weightLbs := person.GetWeightInLbs() + if weightLbs <= 0 { + t.Error("GetWeightInLbs returned invalid value") + } + + interests := person.GetInterests() + if len(interests) > 0 { + firstInterest := interests[0] + if !person.HasInterest(firstInterest) { + t.Errorf("HasInterest returned false for interest %s that should exist", firstInterest) + } + } + + if len(interests) > 0 { + firstInterest := interests[0] + weight := person.GetInterestWeight(firstInterest) + if weight < 1 || weight > 10 { + t.Errorf("GetInterestWeight returned invalid weight %d for interest %s", weight, firstInterest) + } + } +} diff --git a/utils/README.md b/utils/README.md new file mode 100644 index 0000000..91412b2 --- /dev/null +++ b/utils/README.md @@ -0,0 +1,72 @@ +# Utils Package + +This package contains utility functions for the CLIsland game, including character generation. + +## Character Generation + +### `generateCharacters(characters []string, playerSex string) []string` + +Generates a list of character names for the Love Island game based on the player's sex. + +**Parameters:** +- `characters`: A slice of existing character names (can be empty) +- `playerSex`: The player's sex ("male" or "female") + +**Returns:** +- A slice of character names (not including the player) + +**Logic:** +- If player is "male": generates 5 boys and 6 girls (11 total characters) +- If player is "female": generates 6 boys and 5 girls (11 total characters) +- The result is shuffled to randomize the order + +**Example:** +```go +// For a male player +characters := generateCharacters([]string{}, "male") +// Returns: ["James", "Emma", "Sophie", "Alex", "Mia", "Liam", "Olivia", "Noah", "Ava", "Ethan", "Isabella"] + +// For a female player +characters := generateCharacters([]string{}, "female") +// Returns: ["Liam", "Emma", "Noah", "Olivia", "Ethan", "Ava", "James", "Sophie", "Alex", "Mia", "Isabella"] +``` + +### Helper Functions + +#### `generateRandomNameFromFile(sex string) string` + +Reads a random name from the appropriate name file based on sex. + +**Parameters:** +- `sex`: "male" or "female" + +**Returns:** +- A random name from the corresponding file (`data/names/boys.txt` or `data/names/girls.txt`) + +#### `shuffleStrings(slice []string)` + +Shuffles a slice of strings in place using the Fisher-Yates algorithm. + +**Parameters:** +- `slice`: The slice of strings to shuffle + +## Usage + +```go +package main + +import "github.com/TuringProblem/CLIsland/utils" + +func main() { + // Generate characters for a male player + characters := utils.generateCharacters([]string{}, "male") + fmt.Printf("Generated %d characters: %v\n", len(characters), characters) +} +``` + +## Testing + +Run the tests with: +```bash +go test ./utils/ -v +``` \ No newline at end of file diff --git a/utils/character_generator.go b/utils/character_generator.go new file mode 100644 index 0000000..69dd4c6 --- /dev/null +++ b/utils/character_generator.go @@ -0,0 +1,80 @@ +package utils + +import ( + "bufio" + "math/rand" + "os" + "strings" + "time" +) + +func GenerateRandomNameFromFile(sex string) string { + var filename string + if sex == "male" { + filename = "data/names/boys.txt" + } else { + filename = "data/names/girls.txt" + } + + file, err := os.Open(filename) + if err != nil { + if sex == "male" { + return "Alex" + } + return "Emma" + } + defer file.Close() + + var names []string + scanner := bufio.NewScanner(file) + for scanner.Scan() { + name := strings.TrimSpace(scanner.Text()) + if name != "" { + names = append(names, name) + } + } + + if len(names) == 0 { + if sex == "male" { + return "Alex" + } + return "Emma" + } + + rand.New(rand.NewSource(time.Now().UnixNano())) + return names[rand.Intn(len(names))] +} + +func ShuffleStrings(slice []string) { + rand.New(rand.NewSource(time.Now().UnixNano())) + rand.Shuffle(len(slice), func(i, j int) { + slice[i], slice[j] = slice[j], slice[i] + }) +} + +// GenerateCharacters generates a list of character names based on the player's sex +// If playerSex is "male", generates 5 boys and 6 girls +// If playerSex is "female", generates 6 boys and 5 girls +func GenerateCharacters(characters []string, playerSex string) []string { + var result []string + + if playerSex == "male" { + for i := 0; i < 5; i++ { + result = append(result, GenerateRandomNameFromFile("male")) + } + for i := 0; i < 6; i++ { + result = append(result, GenerateRandomNameFromFile("female")) + } + } else { + for i := 0; i < 6; i++ { + result = append(result, GenerateRandomNameFromFile("male")) + } + for i := 0; i < 5; i++ { + result = append(result, GenerateRandomNameFromFile("female")) + } + } + + ShuffleStrings(result) + + return result +} diff --git a/utils/character_generator_test.go b/utils/character_generator_test.go new file mode 100644 index 0000000..21bcf78 --- /dev/null +++ b/utils/character_generator_test.go @@ -0,0 +1,40 @@ +package utils + +import ( + "testing" +) + +func TestGenerateCharacters(t *testing.T) { + maleCharacters := GenerateCharacters([]string{}, "male") + if len(maleCharacters) != 11 { + t.Errorf("Expected 11 characters for male player, got %d", len(maleCharacters)) + } + + femaleCharacters := GenerateCharacters([]string{}, "female") + if len(femaleCharacters) != 11 { + t.Errorf("Expected 11 characters for female player, got %d", len(femaleCharacters)) + } + + for i, name := range maleCharacters { + if name == "" { + t.Errorf("Character %d has empty name", i) + } + } + + for i, name := range femaleCharacters { + if name == "" { + t.Errorf("Character %d has empty name", i) + } + } +} + +func TestGenerateRandomNameFromFile(t *testing.T) { + maleName := GenerateRandomNameFromFile("male") + if maleName == "" { + t.Error("Generated male name is empty") + } + femaleName := GenerateRandomNameFromFile("female") + if femaleName == "" { + t.Error("Generated female name is empty") + } +} diff --git a/utils/example_usage.go b/utils/example_usage.go new file mode 100644 index 0000000..c59a0d4 --- /dev/null +++ b/utils/example_usage.go @@ -0,0 +1,13 @@ +package utils + +import "fmt" + +func ExampleUsage() { + malePlayerCharacters := GenerateCharacters([]string{}, "male") + fmt.Printf("Characters for male player: %v\n", malePlayerCharacters) + fmt.Printf("Total characters: %d\n", len(malePlayerCharacters)) + + femalePlayerCharacters := GenerateCharacters([]string{}, "female") + fmt.Printf("Characters for female player: %v\n", femalePlayerCharacters) + fmt.Printf("Total characters: %d\n", len(femalePlayerCharacters)) +} diff --git a/utils/filters.go b/utils/filters.go index 83b2362..b2f4714 100644 --- a/utils/filters.go +++ b/utils/filters.go @@ -1,3 +1,4 @@ +// Package utils provides utility functions for the CLIsland game. package utils func Compose[T, U, V any](f func(U) V, g func(T) U) func(T) V { diff --git a/utils/person_generator.go b/utils/person_generator.go new file mode 100644 index 0000000..ed1f0e8 --- /dev/null +++ b/utils/person_generator.go @@ -0,0 +1,194 @@ +package utils + +import ( + "fmt" + "math/rand" + "time" +) + +type Person struct { + Name string + Age int + Height float64 // inches + Weight float64 + Sex string + Interests Interests +} + +type Interest string + +const ( + Music Interest = "Music" + Sports Interest = "Sports" + Reading Interest = "Reading" + Writing Interest = "Writing" + Coding Interest = "Coding" + Art Interest = "Art" + Travel Interest = "Travel" + Swimming Interest = "Swimming" + Gaming Interest = "Gaming" + Lifting Interest = "Lifting" + Cooking Interest = "Cooking" + Cleaning Interest = "Cleaning" + Shopping Interest = "Shopping" + Partying Interest = "Partying" + Sleeping Interest = "Sleeping" +) + +type Interests struct { + InterestType map[Interest]int // returns the weight of the interest in that category +} + +func generateRandomAge() int { + rand.New(rand.NewSource(time.Now().UnixNano())) + return rand.Intn(18) + 18 // 18-35 years old +} + +func generateRandomHeight(sex string) float64 { + rand.New(rand.NewSource(time.Now().UnixNano())) + if sex == "male" { + return float64(rand.Intn(14) + 65) + } else { + return float64(rand.Intn(13) + 60) + } +} + +func generateRandomWeight(height float64, sex string) float64 { + rand.New(rand.NewSource(time.Now().UnixNano())) + + minBMI := 18.5 + maxBMI := 25.0 + + heightMeters := height * 0.0254 + + minWeight := minBMI * heightMeters * heightMeters + maxWeight := maxBMI * heightMeters * heightMeters + + weightRange := maxWeight - minWeight + randomWeight := minWeight + (rand.Float64() * weightRange) + + return float64(int(randomWeight*10)) / 10 +} + +func generateRandomInterests() Interests { + rand.New(rand.NewSource(time.Now().UnixNano())) + + allInterests := []Interest{ + Music, Sports, Reading, Writing, Coding, Art, Travel, Swimming, + Gaming, Lifting, Cooking, Cleaning, Shopping, Partying, Sleeping, + } + + interests := Interests{ + InterestType: make(map[Interest]int), + } + + // Select 3-6 random interests + numInterests := rand.Intn(4) + 3 // 3-6 interests + + // Shuffle interests and take the first numInterests + shuffled := make([]Interest, len(allInterests)) + copy(shuffled, allInterests) + rand.Shuffle(len(shuffled), func(i, j int) { + shuffled[i], shuffled[j] = shuffled[j], shuffled[i] + }) + + // Assign random weights (1-10) to selected interests + for i := 0; i < numInterests; i++ { + weight := rand.Intn(10) + 1 // 1-10 + interests.InterestType[shuffled[i]] = weight + } + + return interests +} + +// GeneratePerson creates a complete Person object with random attributes +func GeneratePerson(sex string) Person { + name := GenerateRandomNameFromFile(sex) + age := generateRandomAge() + height := generateRandomHeight(sex) + weight := generateRandomWeight(height, sex) + interests := generateRandomInterests() + + return Person{ + Name: name, + Age: age, + Height: height, + Weight: weight, + Sex: sex, + Interests: interests, + } +} + +// GeneratePersonList creates a list of Person objects based on the player's sex +// If playerSex is "male", generates 5 boys and 6 girls +// If playerSex is "female", generates 6 boys and 5 girls +func GeneratePersonList(playerSex string) []Person { + var people []Person + + if playerSex == "male" { + // Generate 5 boys and 6 girls for male player + for i := 0; i < 5; i++ { + people = append(people, GeneratePerson("male")) + } + for i := 0; i < 6; i++ { + people = append(people, GeneratePerson("female")) + } + } else { + // Generate 6 boys and 5 girls for female player + for i := 0; i < 6; i++ { + people = append(people, GeneratePerson("male")) + } + for i := 0; i < 5; i++ { + people = append(people, GeneratePerson("female")) + } + } + + rand.New(rand.NewSource(time.Now().UnixNano())) + rand.Shuffle(len(people), func(i, j int) { + people[i], people[j] = people[j], people[i] + }) + + return people +} + +// GetInterests returns a slice of interests for a person +func (p *Person) GetInterests() []Interest { + var interests []Interest + for k := range p.Interests.InterestType { + interests = append(interests, k) + } + return interests +} + +// GetInterestWeight returns the weight of a specific interest +func (p *Person) GetInterestWeight(interest Interest) int { + return p.Interests.InterestType[interest] +} + +// HasInterest checks if a person has a specific interest +func (p *Person) HasInterest(interest Interest) bool { + _, exists := p.Interests.InterestType[interest] + return exists +} + +// GetHeightInFeet returns height in feet and inches format +func (p *Person) GetHeightInFeet() string { + feet := int(p.Height) / 12 + inches := int(p.Height) % 12 + return fmt.Sprintf("%d'%d\"", feet, inches) +} + +// GetHeightInCm returns height in centimeters +func (p *Person) GetHeightInCm() float64 { + return p.Height * 2.54 +} + +// GetWeightInKg returns weight in kilograms +func (p *Person) GetWeightInKg() float64 { + return p.Weight +} + +// GetWeightInLbs returns weight in pounds +func (p *Person) GetWeightInLbs() float64 { + return p.Weight * 2.20462 +} diff --git a/utils/tests/person_generator_test.go b/utils/tests/person_generator_test.go new file mode 100644 index 0000000..1d9d702 --- /dev/null +++ b/utils/tests/person_generator_test.go @@ -0,0 +1,116 @@ +package utils + +import ( + "testing" +) + +func TestGeneratePerson(t *testing.T) { + // Test generating a male person + malePerson := GeneratePerson("male") + if malePerson.Name == "" { + t.Error("Generated male person has empty name") + } + if malePerson.Age < 18 || malePerson.Age > 35 { + t.Errorf("Generated male person age %d is outside valid range (18-35)", malePerson.Age) + } + if malePerson.Sex != "male" { + t.Errorf("Generated male person has wrong sex: %s", malePerson.Sex) + } + if malePerson.Height < 65 || malePerson.Height > 78 { + t.Errorf("Generated male person height %.1f is outside valid range (65-78 inches)", malePerson.Height) + } + if len(malePerson.GetInterests()) < 3 || len(malePerson.GetInterests()) > 6 { + t.Errorf("Generated male person has %d interests, expected 3-6", len(malePerson.GetInterests())) + } + + // Test generating a female person + femalePerson := GeneratePerson("female") + if femalePerson.Name == "" { + t.Error("Generated female person has empty name") + } + if femalePerson.Age < 18 || femalePerson.Age > 35 { + t.Errorf("Generated female person age %d is outside valid range (18-35)", femalePerson.Age) + } + if femalePerson.Sex != "female" { + t.Errorf("Generated female person has wrong sex: %s", femalePerson.Sex) + } + if femalePerson.Height < 60 || femalePerson.Height > 72 { + t.Errorf("Generated female person height %.1f is outside valid range (60-72 inches)", femalePerson.Height) + } + if len(femalePerson.GetInterests()) < 3 || len(femalePerson.GetInterests()) > 6 { + t.Errorf("Generated female person has %d interests, expected 3-6", len(femalePerson.GetInterests())) + } +} + +func TestGeneratePersonList(t *testing.T) { + // Test for male player + malePlayerPeople := GeneratePersonList("male") + if len(malePlayerPeople) != 11 { + t.Errorf("Expected 11 people for male player, got %d", len(malePlayerPeople)) + } + + // Test for female player + femalePlayerPeople := GeneratePersonList("female") + if len(femalePlayerPeople) != 11 { + t.Errorf("Expected 11 people for female player, got %d", len(femalePlayerPeople)) + } + + // Test that all people have valid attributes + for i, person := range malePlayerPeople { + if person.Name == "" { + t.Errorf("Person %d has empty name", i) + } + if person.Age < 18 || person.Age > 35 { + t.Errorf("Person %d has invalid age: %d", i, person.Age) + } + if len(person.GetInterests()) < 3 || len(person.GetInterests()) > 6 { + t.Errorf("Person %d has invalid number of interests: %d", i, len(person.GetInterests())) + } + } +} + +func TestPersonMethods(t *testing.T) { + person := GeneratePerson("male") + + // Test GetHeightInFeet + heightFeet := person.GetHeightInFeet() + if heightFeet == "" { + t.Error("GetHeightInFeet returned empty string") + } + + // Test GetHeightInCm + heightCm := person.GetHeightInCm() + if heightCm <= 0 { + t.Error("GetHeightInCm returned invalid value") + } + + // Test GetWeightInKg + weightKg := person.GetWeightInKg() + if weightKg <= 0 { + t.Error("GetWeightInKg returned invalid value") + } + + // Test GetWeightInLbs + weightLbs := person.GetWeightInLbs() + if weightLbs <= 0 { + t.Error("GetWeightInLbs returned invalid value") + } + + // Test HasInterest + interests := person.GetInterests() + if len(interests) > 0 { + firstInterest := interests[0] + if !person.HasInterest(firstInterest) { + t.Errorf("HasInterest returned false for interest %s that should exist", firstInterest) + } + } + + // Test GetInterestWeight + if len(interests) > 0 { + firstInterest := interests[0] + weight := person.GetInterestWeight(firstInterest) + if weight < 1 || weight > 10 { + t.Errorf("GetInterestWeight returned invalid weight %d for interest %s", weight, firstInterest) + } + } +}