CLAUDE: Reorganize Week 6 documentation and separate player model specifications

Split player model architecture into dedicated documentation files for clarity
and maintainability. Added Phase 1 status tracking and comprehensive player
model specs covering API models, game models, mappers, and testing strategy.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2025-10-25 23:48:57 -05:00
parent f3238c4e6d
commit f9aa653c37
11 changed files with 4479 additions and 6 deletions

View File

@ -0,0 +1,227 @@
# Phase 1 Catch-Up Plan
**Created**: 2025-10-25
**Status**: Planning
**Reason**: Jumped ahead to Phase 2 (game engine) without completing all Phase 1 deliverables
## Current State Assessment
### ✅ What's Complete (Backend Infrastructure)
1. **Backend Foundation**
- FastAPI server with async support
- PostgreSQL database with SQLAlchemy 2.0
- Pydantic models for validation
- Logging system with rotating file handlers
- Health check endpoints (`/api/health`, `/api/health/db`)
- CORS configuration
2. **Database Layer**
- All database models created (Game, Play, Lineup, GameSession, RosterLink, GameCardsetLink)
- Async database operations (DatabaseOperations class)
- Polymorphic model support (PD/SBA)
- State persistence and recovery
3. **WebSocket Infrastructure**
- Socket.io integration with FastAPI
- ConnectionManager class for room management
- Basic event handlers (connect, disconnect, join_game, leave_game, heartbeat)
- JWT token verification in connection handler
4. **Authentication Stubs**
- JWT token creation (`create_token()`)
- JWT token verification (`verify_token()`)
- Basic auth endpoints (`/api/auth/token`, `/api/auth/verify`)
### ⚠️ What's Incomplete/Missing
1. **Discord OAuth** - STUB ONLY
- `/api/auth/token` endpoint exists but just creates tokens from provided data
- No actual OAuth flow with Discord
- No Discord API integration
- No session management with Discord
2. **Frontend Applications** - SCAFFOLDING ONLY
- `frontend-sba/` ✅ Nuxt 3 initialized with dependencies
- `frontend-pd/` ✅ Nuxt 3 initialized with dependencies
- ❌ Only bare `app.vue` - no pages, components, or logic
- ❌ No authentication UI
- ❌ No game creation UI
- ❌ No gameplay UI
- ❌ No WebSocket integration
- ❌ No Pinia stores
- ❌ No composables
3. **WebSocket Game Events** - BASIC ONLY
- Only have: connect, disconnect, join_game, leave_game, heartbeat
- Missing: game action events, state updates, error handling
- No integration with GameEngine for real-time updates
4. **Full Auth Flow** - INCOMPLETE
- Can create tokens, but no OAuth flow
- No user database/storage
- No session persistence
- No refresh tokens
## Prioritization Strategy
### Option A: Skip Phase 1, Continue with Phase 2/3 (Recommended)
**Rationale**:
- Game engine is the core value proposition
- Can build and test game logic without frontend
- OAuth and frontend can be added later without blocking progress
- Manual testing via Python scripts works fine
**Benefits**:
- Faster time to working game engine
- Can validate game mechanics without UI complexity
- Easier to test and debug game logic
**Risks**:
- No user-facing application yet
- Can't demo to stakeholders easily
### Option B: Complete Phase 1 Fully
**Rationale**:
- Follow the original plan
- Have end-to-end system working
- Can demo to users immediately
**Benefits**:
- Complete vertical slice
- Better for early user feedback
**Risks**:
- Significant frontend work (4-6 weeks minimum)
- Delays game engine enhancements
- May build UI for features that change
### Option C: Hybrid Approach (Build Minimum Viable Frontend)
**Rationale**:
- Build just enough frontend to test game flows
- Skip Discord OAuth, use simple token auth
- Single-page app instead of full Nuxt setup
**Benefits**:
- Can see game in action
- Minimal frontend work (1-2 weeks)
- Unblocks user testing
## Recommended Plan: **Option A** (Skip Phase 1, Continue Phase 2/3)
### Reasoning
1. **Game Engine is Core IP**: The game simulation logic is what makes this project unique. Frontend can be built on any framework later.
2. **Phase 2 is Working Well**: We have a solid game engine with:
- Complete at-bat flow working
- Dice system with audit trail
- State management and persistence
- 54+ unit tests
- Integration tests
3. **OAuth is Not Blocking**: We can:
- Use stub auth for development/testing
- Add real Discord OAuth later (2-3 days of work)
- Many games launch without OAuth initially
4. **Frontend Can Wait**:
- Frontend is 4-6 weeks of work minimum
- Game logic may still evolve
- Building UI now might mean rebuilding later
- Can use scripts/Postman for testing
5. **Current Approach is Working**:
- Manual test scripts validate functionality
- pytest integration tests cover critical paths
- Can run complete games via Python
### Immediate Next Steps (Continue Phase 2/3)
**Week 6 (Phase 2)**: League Features & Integration
- [ ] League configuration system (SBA and PD configs)
- [ ] Complete result charts (beyond simplified)
- [ ] API client for league data
- [ ] Player/card data integration
**Weeks 7-9 (Phase 3)**: Complete Game Features
- [ ] All strategic decisions (steals, bunts, hit-and-run)
- [ ] Substitution system
- [ ] Pitcher fatigue and changes
- [ ] AI opponent implementation
**Weeks 10-11 (Phase 4)**: Polish & Testing
- [ ] Comprehensive test coverage
- [ ] Performance optimization
- [ ] Game state recovery testing
- [ ] Documentation
### When to Revisit Phase 1
**Trigger Points**:
1. **After Phase 3 Complete**: Game mechanics fully implemented and tested
2. **When Ready for User Testing**: Need UI for stakeholders to test
3. **Before Public Launch**: Real auth required for production
**Estimated Effort**:
- Discord OAuth integration: 2-3 days
- Basic frontend (SBA league only): 2-3 weeks
- Full frontend (both leagues): 4-6 weeks
- WebSocket game events: 1 week
## If You Choose to Complete Phase 1
### Phase 1 Completion Tasks
#### 1. Discord OAuth Implementation (3-5 days)
- [ ] Create Discord app in Discord Developer Portal
- [ ] Implement OAuth callback endpoint
- [ ] Add user model to database
- [ ] Store Discord user data
- [ ] Generate and manage sessions
- [ ] Add refresh token logic
#### 2. Frontend - SBA League (2-3 weeks)
- [ ] Initialize Nuxt 3 project (frontend-sba/)
- [ ] Setup Tailwind CSS
- [ ] Create authentication pages (login, callback)
- [ ] Create game lobby (list games, create game)
- [ ] Create game room (player view)
- [ ] WebSocket integration for real-time updates
- [ ] Basic UI components (scoreboard, lineup, decision panel)
#### 3. Frontend - PD League (1-2 weeks after SBA)
- [ ] Clone SBA frontend structure
- [ ] Customize branding/styling for PD
- [ ] Adjust for PD-specific features
- [ ] Test with PD league data
#### 4. WebSocket Game Events (1 week)
- [ ] Implement game state broadcast
- [ ] Add decision submission events
- [ ] Add play resolution events
- [ ] Handle errors and reconnection
- [ ] Test with multiple clients
## Decision Required
**What should we do?**
**Recommended**: Continue with Phase 2/3, defer Phase 1 completion until game engine is solid.
**Alternative**: Complete Phase 1 now if stakeholder demos are critical.
**Hybrid**: Build minimal frontend (just game room, no OAuth) for internal testing.
---
## Notes
- This document captures the gap between planned Phase 1 and actual implementation
- The backend infrastructure we have is **sufficient** for game engine development
- Frontend and OAuth are **nice to have**, not **required** for core development
- We can always come back and complete Phase 1 later
## Updates
- **2025-10-25**: Initial assessment created
- **Next Review**: After Week 6 (Phase 2 complete) or when stakeholder demo is requested

View File

@ -0,0 +1,224 @@
# Phase 1 Status Summary
**Date**: 2025-10-25
**Assessment**: Phase 1 is ~60% complete
## Quick Status
| Component | Status | % Complete | Notes |
|-----------|--------|------------|-------|
| Backend Infrastructure | ✅ Complete | 100% | FastAPI, PostgreSQL, logging, health checks |
| Database Models | ✅ Complete | 100% | All tables, polymorphic models, operations |
| WebSocket Infrastructure | ✅ Complete | 100% | ConnectionManager, basic handlers |
| Auth Stubs | ✅ Complete | 100% | JWT creation/verification working |
| Discord OAuth | ❌ Stub Only | 20% | Can create tokens, but no OAuth flow |
| Frontend Scaffold | ✅ Complete | 100% | Nuxt 3 initialized, dependencies installed |
| Frontend Implementation | ❌ Not Started | 5% | Only bare app.vue, no pages/components |
| WebSocket Game Events | 🟡 Partial | 40% | Basic events only, no game-specific |
**Overall Phase 1 Completion**: ~60%
## What's Working ✅
### Backend (90% Complete)
- FastAPI server running on port 8000
- PostgreSQL database with all tables
- Async SQLAlchemy with proper session management
- Logging system with rotating file handlers
- Health check endpoints: `/api/health` and `/api/health/db`
- CORS configured for local development
- JWT token creation and verification
- WebSocket server with Socket.io
- ConnectionManager for room management
- Basic WebSocket events (connect, disconnect, join/leave game, heartbeat)
### Frontend (15% Complete)
- ✅ Nuxt 3 initialized for both leagues
- ✅ Dependencies installed (Tailwind, Pinia, Socket.io-client, Axios)
- ✅ Config files in place
- ✅ TypeScript strict mode enabled
- ❌ No pages implemented
- ❌ No components implemented
- ❌ No WebSocket plugin implemented
- ❌ No state management (Pinia stores) implemented
## What's Missing ❌
### 1. Discord OAuth (Critical for Production)
**Current State**: Stub endpoints exist but no actual OAuth flow
**Missing**:
- Discord Developer Portal app setup
- OAuth callback endpoint implementation
- User database table/model
- Session management
- Refresh token handling
- User data storage
**Effort**: 2-3 days
**Priority**: Medium (can use stub auth for development)
### 2. Frontend Pages & Components (Critical for User Testing)
**Current State**: Only `app.vue` exists (blank)
**Missing**:
- `/pages/index.vue` - Home/dashboard
- `/pages/auth/login.vue` - Login page
- `/pages/auth/callback.vue` - OAuth callback
- `/pages/games/create.vue` - Create game
- `/pages/games/[id].vue` - Game room
- `/pages/games/history.vue` - Game history
- `/pages/spectate/[id].vue` - Spectator view
- All components (scoreboard, lineup, decision panels, etc.)
- All composables (useAuth, useWebSocket, useGameState)
- All Pinia stores (auth, game, ui)
- WebSocket plugin integration
**Effort**: 4-6 weeks for full implementation
**Priority**: High if need user demos, Low if focusing on game engine
### 3. WebSocket Game Events (Required for Live Gameplay)
**Current State**: Basic connection events only
**Missing**:
- `submit_defensive_decision` event
- `submit_offensive_decision` event
- `resolve_play` event
- `game_state_update` broadcast
- `play_result` broadcast
- `error` handling for game actions
- Integration with GameEngine
**Effort**: 1 week
**Priority**: Medium (can test via Python scripts)
## Recommendations
### Option 1: Continue with Game Engine (Recommended) ⭐
**Skip frontend, complete game engine first**
**Reasoning**:
- Game engine is the unique value (60% complete)
- Can test via Python scripts (already working)
- Frontend can be built after engine is solid
- May need to rebuild UI if engine changes
**Timeline**:
- Week 6: League features (configs, API client)
- Weeks 7-9: Complete game features (Phase 3)
- Weeks 10-11: Polish & testing (Phase 4)
- **Then** build frontend with stable engine
**Pros**:
- Fastest path to working game simulation
- Can validate mechanics without UI complexity
- Easier to test and debug
**Cons**:
- No visual demos for stakeholders
- Can't get user feedback on UX
### Option 2: Build Minimum Viable Frontend
**Build just enough UI to play a game**
**What to Build**:
- Simple login (stub auth, no Discord OAuth)
- Game lobby (list games, create game)
- Game room (minimal UI - scoreboard, decision buttons)
- WebSocket integration for real-time updates
**Estimated Effort**: 2-3 weeks
**Timeline**: Complete by mid-November
**Pros**:
- Can demo to stakeholders
- Get early UX feedback
- Validates full stack integration
**Cons**:
- Delays game engine work
- May need to rebuild if engine changes
- Still no real OAuth
### Option 3: Complete Phase 1 Fully
**Build everything as originally planned**
**Includes**:
- Full Discord OAuth
- Complete SBA frontend
- Complete PD frontend
- All WebSocket game events
- Polished UI
**Estimated Effort**: 6-8 weeks
**Timeline**: Complete by late December
**Pros**:
- Full vertical slice
- Ready for user testing
- Complete feature parity
**Cons**:
- Significant time investment
- Delays core game engine work
- Engine may evolve, requiring UI changes
## Decision Matrix
| Factor | Option 1 (Engine First) | Option 2 (Min Frontend) | Option 3 (Full Phase 1) |
|--------|------------------------|------------------------|------------------------|
| Time to working game | ⭐⭐⭐ Fast (4 weeks) | ⭐⭐ Medium (6 weeks) | ⭐ Slow (12+ weeks) |
| Demo-able to users | ❌ No | ✅ Yes | ✅ Yes |
| Test coverage | ✅ Excellent | ⭐ Good | ⭐⭐ Excellent |
| Flexibility | ✅ High | ⭐ Medium | ❌ Low (committed to UI) |
| Risk | ⭐ Low | ⭐⭐ Medium | ⭐⭐⭐ High (scope) |
| User feedback | ❌ No | ✅ Yes | ✅ Yes |
## Our Recommendation
**Choose Option 1: Continue with Game Engine First**
### Next Steps (Weeks 6-11)
1. **Week 6** (Phase 2): League configuration system, result charts, API client
2. **Weeks 7-9** (Phase 3): Complete game features (decisions, substitutions, AI)
3. **Weeks 10-11** (Phase 4): Polish, testing, performance optimization
4. **Weeks 12-17**: Build frontend with stable engine as foundation
### When to Build Frontend
- **Trigger**: After Phase 3 complete (game engine feature-complete)
- **Why**: Engine will be stable, fewer UI changes needed
- **How Long**: 4-6 weeks for both leagues
- **What to Build**: Full implementation with Discord OAuth
### Interim Testing Strategy
- Continue using Python test scripts (working well)
- Integration tests cover critical paths
- Can simulate any game scenario programmatically
- No dependency on UI for validation
## Status vs. Plan
### Original Phase 1 Plan (from 01-infrastructure.md)
- [x] PostgreSQL database created ✅
- [x] FastAPI server running ✅
- [x] Socket.io WebSocket server ✅
- [x] Database tables created ✅
- [x] Logging system working ✅
- [ ] Discord OAuth implemented ❌ (stub only)
- [x] Nuxt 3 apps initialized ✅
- [ ] Frontend pages & components ❌ (not implemented)
- [ ] WebSocket game events ⚠️ (basic only)
- [x] Health check endpoints ✅
**Completion**: 7/10 tasks complete (70%)
### Actual Achievement
We have a **solid backend foundation** that's fully sufficient for game engine development. The missing pieces (OAuth, frontend UI) can be added later without impacting core game simulation work.
## Conclusion
**Phase 1 is ~60% complete**, but what's complete is the **right 60%** for continuing with game engine development. The backend infrastructure is solid and working well.
**Recommendation**: Proceed with Phase 2/3 (game engine), defer frontend and OAuth completion until engine is feature-complete and tested.
This approach minimizes risk of rebuilding UI when engine evolves and gets you to a working game simulation faster.

View File

@ -2,21 +2,42 @@
**Duration**: Week 6 of Phase 2
**Prerequisites**: Week 5 Complete (Game engine working)
**Focus**: League-specific features, polymorphic players, and end-to-end integration
**Focus**: League-specific features, configurations, and API integration
**Status**: Planning Complete (2025-10-25)
---
## ⚠️ IMPORTANT: Player Models Documentation
Player model implementation is now documented separately due to complexity:
**→ See [02-week6-player-models-overview.md](./02-week6-player-models-overview.md) for complete player model specifications**
This includes:
- Two-layer architecture (API models + Game models)
- Real API data structures for PD and SBA
- Mapper layer for transformations
- Complete implementation guide
**This file focuses on configs and result charts only.**
---
## Overview
Implement league differentiation (SBA vs PD), polymorphic player models, league configurations, and API client for fetching team/roster data. Complete Phase 2 with full integration testing.
Complete Phase 2 by implementing:
1. **Player Models** (see separate documentation above)
2. **League Configuration System** (this file)
3. **Result Chart System** (this file)
4. **PlayResolver Integration** (this file)
## Goals
By end of Week 6:
- ✅ Polymorphic player model system (BasePlayer → SbaPlayer/PdPlayer)
- ✅ Player models (API + Game layers) - **See separate docs**
- ✅ League configuration framework (BaseConfig → SbaConfig/PdConfig)
- ✅ Result chart system for both leagues
- ✅ API client for league REST APIs
- ✅ PlayResolver updated to use configs and PD ratings
- ✅ Complete end-to-end at-bat for both SBA and PD
- ✅ Full test coverage
@ -33,13 +54,22 @@ By end of Week 6:
│ ↓ ↓ │
│ SbaConfig PdConfig │
│ ↓ ↓ │
│ SbaPlayer PdPlayer
│ SbaPlayer PdPlayer (see separate docs)
└──────────────────────────────────────────────────────────┘
```
## Components to Build
### 1. Polymorphic Player Models (`backend/app/models/player_models.py`)
### 1. Player Models
**→ MOVED TO SEPARATE DOCUMENTATION**
See [02-week6-player-models-overview.md](./02-week6-player-models-overview.md) and the `player-model-specs/` directory for:
- API models (exact API responses)
- Game models (gameplay-optimized)
- Mappers and factories
- API client
- Complete testing strategy
Abstract player model with league-specific implementations.

View File

@ -0,0 +1,312 @@
# Week 6: Player Models & League Integration - Overview
**Created**: 2025-10-25
**Status**: Planning Complete, Ready for Implementation
**Prerequisites**: Week 5 Complete (Game engine working)
**Focus**: League-specific player models and API integration
---
## Quick Summary
Implement two-layer player model system:
1. **API Models** - Exact match to external league APIs (PD & SBA)
2. **Game Models** - Optimized for gameplay with only essential fields
This allows us to:
- Deserialize API responses with full type safety
- Work with clean, minimal data in game engine
- Support both leagues with different data structures
---
## Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ External League APIs │
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ PD API │ │ SBA API │ │
│ │ - Player │ │ - Player │ │
│ │ - Batting Ratings │ │ │ │
│ │ - Pitching Ratings │ │ │ │
│ └──────────────────────┘ └──────────────────────┘ │
└────────────────┬──────────────────────┬─────────────────────┘
│ │
↓ ↓
┌─────────────────────────────────────────────────────────────┐
│ API Response Models (api_models.py) │
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ PdPlayerApi │ │ SbaPlayerApi │ │
│ │ PdBattingRatingsApi │ │ (with nested Team, │ │
│ │ PdPitchingRatingsApi│ │ Manager, Division) │ │
│ └──────────────────────┘ └──────────────────────┘ │
└────────────────┬──────────────────────┬─────────────────────┘
│ │
↓ ↓
┌─────────────────────────────────────────────────────────────┐
│ Mapper Layer (PlayerMapper class) │
│ Transforms: API → Game Models │
│ - Extracts essential fields │
│ - Flattens nested structures │
│ - Normalizes position data (pos_1-8 → List[str]) │
└────────────────┬──────────────────────┬─────────────────────┘
│ │
↓ ↓
┌─────────────────────────────────────────────────────────────┐
│ Game Models (player_models.py) │
│ ┌──────────────────────┐ ┌──────────────────────┐ │
│ │ PdPlayer │ │ SbaPlayer │ │
│ │ - Basic fields │ │ - Basic fields │ │
│ │ - Batting ratings │ │ - Simplified data │ │
│ │ - Pitching ratings │ │ │ │
│ └──────────────────────┘ └──────────────────────┘ │
└────────────────┬──────────────────────┬─────────────────────┘
│ │
↓ ↓
┌─────────────────────────────────────────────────────────────┐
│ Game Engine & Play Resolver │
│ - Uses game models during play resolution │
│ - PD: Uses outcome probabilities from ratings │
│ - SBA: Uses simplified result charts │
└─────────────────────────────────────────────────────────────┘
```
---
## Why Two Layers?
### Problem
- External APIs return **massive** nested JSON (team, division, cardset, rarity, etc.)
- Game engine only needs **minimal** data (name, positions, ratings)
- PD needs 3 API calls per player (player + batting ratings + pitching ratings)
### Solution
**Layer 1: API Models** - Match external structure exactly
- Full type safety when deserializing
- All nested objects as Pydantic models
- Easy to maintain when API changes
**Layer 2: Game Models** - Only what's needed for gameplay
- Clean, minimal data
- Fast serialization for WebSocket
- Easy to work with in game logic
**Mapper Layer** - Transform between them
- Extract essential fields
- Combine multiple API calls (PD: player + ratings)
- Normalize differences between leagues
---
## Detailed Documentation
This plan is split across multiple focused files:
### Core Specifications
1. **[API Models - PD](./player-model-specs/api-models-pd.md)**
- All PD API response models
- JSON examples
- Nested structures (Cardset, Rarity, MlbPlayer)
- Batting & pitching ratings
2. **[API Models - SBA](./player-model-specs/api-models-sba.md)**
- SBA API response models
- JSON examples
- Nested structures (Team, Manager, Division)
3. **[Game Models](./player-model-specs/game-models.md)**
- BasePlayer abstract class
- SbaPlayer (game-optimized)
- PdPlayer (game-optimized with ratings)
- Field selection rationale
4. **[Mappers & Factories](./player-model-specs/mappers-and-factories.md)**
- PlayerMapper (API → Game)
- PlayerFactory (create by league)
- Transformation logic
- Position normalization
5. **[API Client](./player-model-specs/api-client.md)**
- LeagueApiClient HTTP client
- Methods for each endpoint
- Error handling
- Usage examples
6. **[Testing Strategy](./player-model-specs/testing-strategy.md)**
- Unit test specifications
- Integration test plans
- Mock data strategy
---
## Implementation Order
### Phase 1: API Models (Day 1)
1. Create `api_models.py`
2. Define all PD API models
3. Define all SBA API models
4. Unit tests with real JSON samples
### Phase 2: Game Models (Day 1-2)
1. Create `player_models.py`
2. Define BasePlayer abstract
3. Define SbaPlayer
4. Define PdPlayer with ratings
5. Unit tests
### Phase 3: Mappers (Day 2)
1. Create PlayerMapper class
2. Implement PD mapping (combine 3 API calls)
3. Implement SBA mapping
4. Unit tests with transformation examples
### Phase 4: API Client (Day 2-3)
1. Create `api_client.py`
2. Implement LeagueApiClient with httpx
3. Implement PD endpoints
4. Implement SBA endpoints
5. Integration tests with mocked responses
### Phase 5: Integration (Day 3)
1. Update `league_configs.py` with API base URLs
2. Update PlayResolver to use PD ratings
3. End-to-end integration tests
4. Performance testing
---
## Key Design Decisions
### Decision 1: Two-Layer Approach
**Chosen**: API models + Game models (with mapper)
**Alternatives Considered**:
- Single model matching API (❌ too much unnecessary data in game engine)
- Single game model with manual dict parsing (❌ no type safety on API responses)
**Rationale**: Separation of concerns, type safety, performance
### Decision 2: Nested Pydantic Models
**Chosen**: Full Pydantic models for all nested objects (Team, Cardset, etc.)
**Alternatives Considered**:
- Dict[str, Any] for nested data (❌ loses type safety)
- Flatten everything to top level (❌ complex mapping logic)
**Rationale**: Type safety, IDE autocomplete, validation
### Decision 3: Position Handling
**Chosen**: Extract `pos_1` through `pos_8``positions: List[str]`
**Rationale**: Cleaner interface, easier to work with in game logic
### Decision 4: PD Ratings Storage
**Chosen**: Store ratings as part of PdPlayer model (vs L and vs R)
**Rationale**: Always needed for play resolution, keep together
### Decision 5: API Base URLs
**Actual URLs** (from your data):
- PD: `https://pd.manticorum.com/`
- SBA: `https://api.sba.manticorum.com/`
---
## File Structure
After implementation, project structure will be:
```
backend/app/
├── models/
│ ├── api_models.py # NEW - External API response models
│ ├── player_models.py # NEW - Game-optimized player models
│ ├── game_models.py # Existing - Game state models
│ └── db_models.py # Existing - Database ORM models
├── data/
│ ├── __init__.py
│ └── api_client.py # NEW - HTTP client for league APIs
├── config/
│ ├── __init__.py
│ ├── base_config.py # To create
│ ├── league_configs.py # To create (with API URLs)
│ └── result_charts.py # To create
└── core/
├── play_resolver.py # UPDATE - Use PD ratings for resolution
└── ... # Existing files
tests/
├── unit/
│ ├── models/
│ │ ├── test_api_models.py # NEW
│ │ └── test_player_models.py # NEW
│ └── data/
│ └── test_mappers.py # NEW
└── integration/
└── data/
└── test_api_client.py # NEW
```
---
## Success Criteria
### Functional Requirements
- ✅ Can fetch PD player from API and deserialize to PdPlayerApi
- ✅ Can fetch PD batting ratings and deserialize
- ✅ Can fetch PD pitching ratings and deserialize
- ✅ Can fetch SBA player from API and deserialize to SbaPlayerApi
- ✅ Can map PD API models → PdPlayer game model
- ✅ Can map SBA API models → SbaPlayer game model
- ✅ PlayerFactory creates correct player type by league_id
- ✅ Positions correctly extracted from pos_1-8 fields
- ✅ PD ratings correctly attached to PdPlayer
### Non-Functional Requirements
- ✅ All API models pass Pydantic validation with real JSON
- ✅ Unit test coverage > 90%
- ✅ API client handles errors gracefully
- ✅ Serialization/deserialization < 10ms per player
- ✅ Type hints validated by mypy
---
## Timeline & Effort
**Estimated Total**: 2-3 days (16-24 hours)
**Breakdown**:
- API Models: 4-6 hours
- Game Models: 3-4 hours
- Mappers: 2-3 hours
- API Client: 4-6 hours
- Testing: 3-5 hours
**Dependencies**:
- httpx library (already in requirements.txt ✅)
- Access to PD and SBA APIs (have URLs ✅)
- Real JSON samples (provided by user ✅)
---
## Next Steps
1. Read detailed specifications in `player-model-specs/` directory
2. Start with API models (PD first, then SBA)
3. Implement game models
4. Create mapper layer
5. Build API client
6. Write comprehensive tests
7. Update PlayResolver to use ratings
---
**Current Status**: 📝 Planning Complete - Ready to start implementation
**Last Updated**: 2025-10-25
**Week**: 6 of Phase 2

View File

@ -0,0 +1,545 @@
# API Client Specification
**Purpose**: HTTP client for fetching player data from league APIs
**File**: `backend/app/data/api_client.py`
---
## Overview
`LeagueApiClient` handles all HTTP communication with external league APIs:
- Fetches player data
- Fetches ratings data (PD only)
- Handles errors and retries
- Returns API models (exact API responses)
---
## LeagueApiClient Class
### Implementation
```python
import logging
from typing import Optional
import httpx
from app.config.league_configs import get_league_config
from app.models.api_models import (
# SBA
SbaPlayerApi,
# PD
PdPlayerApi,
PdBattingRatingsResponseApi,
PdPitchingRatingsResponseApi
)
logger = logging.getLogger(f'{__name__}.LeagueApiClient')
class LeagueApiClient:
"""
HTTP client for league REST APIs
Handles all communication with external PD and SBA APIs.
Returns API models (not game models - that's the mapper's job).
"""
def __init__(self, league_id: str):
"""
Initialize API client for a league
Args:
league_id: 'sba' or 'pd'
"""
self.league_id = league_id
self.config = get_league_config(league_id)
self.base_url = self.config.get_api_base_url()
# Create HTTP client with timeout
self.client = httpx.AsyncClient(
base_url=self.base_url,
timeout=10.0,
follow_redirects=True
)
logger.info(f"LeagueApiClient initialized for {league_id} ({self.base_url})")
async def close(self):
"""Close HTTP client connection"""
await self.client.aclose()
logger.debug(f"LeagueApiClient closed for {self.league_id}")
# -------------------------------------------------------------------------
# SBA API Methods
# -------------------------------------------------------------------------
async def get_sba_player(self, player_id: int) -> SbaPlayerApi:
"""
Fetch SBA player data
Args:
player_id: SBA player ID
Returns:
SbaPlayerApi model with full nested team data
Raises:
httpx.HTTPError: If API call fails
"""
if self.league_id != "sba":
raise ValueError("This method is only for SBA league")
try:
logger.info(f"Fetching SBA player {player_id}")
response = await self.client.get(
f"/players/{player_id}",
params={"short_output": "false"}
)
response.raise_for_status()
data = response.json()
player = SbaPlayerApi(**data)
logger.info(f"Successfully fetched SBA player: {player.name}")
return player
except httpx.HTTPError as e:
logger.error(f"Failed to fetch SBA player {player_id}: {e}")
raise
# -------------------------------------------------------------------------
# PD API Methods
# -------------------------------------------------------------------------
async def get_pd_player(self, player_id: int) -> PdPlayerApi:
"""
Fetch PD player data (basic info, no ratings)
Args:
player_id: PD player ID
Returns:
PdPlayerApi model with cardset/rarity/mlbplayer nested
Raises:
httpx.HTTPError: If API call fails
"""
if self.league_id != "pd":
raise ValueError("This method is only for PD league")
try:
logger.info(f"Fetching PD player {player_id}")
response = await self.client.get(
f"/api/v2/players/{player_id}",
params={"csv": "false"}
)
response.raise_for_status()
data = response.json()
player = PdPlayerApi(**data)
logger.info(f"Successfully fetched PD player: {player.p_name}")
return player
except httpx.HTTPError as e:
logger.error(f"Failed to fetch PD player {player_id}: {e}")
raise
async def get_pd_batting_ratings(
self,
player_id: int
) -> PdBattingRatingsResponseApi:
"""
Fetch PD batting ratings (vs L and vs R)
Args:
player_id: PD player ID
Returns:
PdBattingRatingsResponseApi with 2 ratings (vs L and vs R)
Raises:
httpx.HTTPError: If API call fails
"""
if self.league_id != "pd":
raise ValueError("This method is only for PD league")
try:
logger.info(f"Fetching PD batting ratings for player {player_id}")
response = await self.client.get(
f"/api/v2/battingcardratings/player/{player_id}",
params={"short_output": "false"}
)
response.raise_for_status()
data = response.json()
ratings = PdBattingRatingsResponseApi(**data)
logger.info(f"Successfully fetched batting ratings ({ratings.count} ratings)")
return ratings
except httpx.HTTPError as e:
logger.error(f"Failed to fetch batting ratings for {player_id}: {e}")
raise
async def get_pd_pitching_ratings(
self,
player_id: int
) -> PdPitchingRatingsResponseApi:
"""
Fetch PD pitching ratings (vs L and vs R)
Args:
player_id: PD player ID
Returns:
PdPitchingRatingsResponseApi with 2 ratings (vs L and vs R)
Raises:
httpx.HTTPError: If API call fails (player may not be a pitcher)
"""
if self.league_id != "pd":
raise ValueError("This method is only for PD league")
try:
logger.info(f"Fetching PD pitching ratings for player {player_id}")
response = await self.client.get(
f"/api/v2/pitchingcardratings/player/{player_id}",
params={"short_output": "false"}
)
response.raise_for_status()
data = response.json()
ratings = PdPitchingRatingsResponseApi(**data)
logger.info(f"Successfully fetched pitching ratings ({ratings.count} ratings)")
return ratings
except httpx.HTTPError as e:
logger.error(f"Failed to fetch pitching ratings for {player_id}: {e}")
raise
# -------------------------------------------------------------------------
# Generic Methods (League-Agnostic)
# -------------------------------------------------------------------------
async def get_player(self, player_id: int):
"""
Fetch player for current league (generic method)
Args:
player_id: Player ID in current league
Returns:
SbaPlayerApi or PdPlayerApi depending on league
"""
if self.league_id == "sba":
return await self.get_sba_player(player_id)
elif self.league_id == "pd":
return await self.get_pd_player(player_id)
else:
raise ValueError(f"Unknown league: {self.league_id}")
async def get_batting_ratings(self, player_id: int):
"""
Fetch batting ratings (PD only)
Args:
player_id: PD player ID
Returns:
PdBattingRatingsResponseApi
Raises:
ValueError: If called for SBA league
"""
if self.league_id != "pd":
raise ValueError("Batting ratings only available for PD league")
return await self.get_pd_batting_ratings(player_id)
async def get_pitching_ratings(self, player_id: int):
"""
Fetch pitching ratings (PD only)
Args:
player_id: PD player ID
Returns:
PdPitchingRatingsResponseApi
Raises:
ValueError: If called for SBA league
httpx.HTTPError: If player is not a pitcher
"""
if self.league_id != "pd":
raise ValueError("Pitching ratings only available for PD league")
return await self.get_pd_pitching_ratings(player_id)
```
---
## Usage Examples
### Basic Usage
```python
from app.data.api_client import LeagueApiClient
# Fetch SBA player
async with LeagueApiClient("sba") as client:
player = await client.get_player(12288)
print(f"{player.name} - {player.team.lname}")
# Fetch PD player with ratings
async with LeagueApiClient("pd") as client:
player = await client.get_player(10633)
batting = await client.get_batting_ratings(10633)
print(f"{player.p_name} - {batting.count} ratings")
```
### Context Manager Support
Add async context manager protocol:
```python
class LeagueApiClient:
# ... existing code ...
async def __aenter__(self):
"""Async context manager entry"""
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Async context manager exit"""
await self.close()
```
Usage:
```python
# Automatically closes client
async with LeagueApiClient("pd") as client:
player = await client.get_player(10633)
# client.close() called automatically
```
---
## Error Handling
### HTTP Errors
```python
from httpx import HTTPStatusError
try:
player = await client.get_player(999999)
except HTTPStatusError as e:
if e.response.status_code == 404:
# Player not found
print(f"Player {player_id} does not exist")
elif e.response.status_code == 500:
# Server error
print("API server error, try again later")
else:
# Other error
print(f"Unexpected error: {e}")
```
### Network Errors
```python
from httpx import RequestError
try:
player = await client.get_player(12288)
except RequestError as e:
# Network error (timeout, connection refused, etc.)
print(f"Network error: {e}")
```
### Validation Errors
```python
from pydantic import ValidationError
try:
player = await client.get_player(12288)
except ValidationError as e:
# API response doesn't match expected model
# This indicates API contract changed
logger.error(f"API response validation failed: {e}")
raise
```
---
## Retry Strategy
For production, add retry logic for transient errors:
```python
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
import httpx
class LeagueApiClient:
# ... existing code ...
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=1, min=1, max=10),
retry=retry_if_exception_type(httpx.TimeoutException),
reraise=True
)
async def get_player(self, player_id: int):
"""Fetch player with automatic retry on timeout"""
# ... existing implementation ...
```
---
## Testing Strategy
### Unit Tests with Mocking
```python
import pytest
from unittest.mock import AsyncMock, patch
from app.data.api_client import LeagueApiClient
@pytest.mark.asyncio
async def test_get_sba_player_success(mock_sba_response):
"""Test successful SBA player fetch"""
# Mock httpx client
with patch('httpx.AsyncClient') as mock_client:
mock_response = AsyncMock()
mock_response.json.return_value = mock_sba_response
mock_response.raise_for_status = AsyncMock()
mock_client.return_value.get = AsyncMock(return_value=mock_response)
client = LeagueApiClient("sba")
player = await client.get_player(12288)
assert player.name == "Ronald Acuna Jr"
assert player.team.lname == "West Virginia Black Bears"
@pytest.mark.asyncio
async def test_get_pd_player_404():
"""Test 404 error handling"""
with patch('httpx.AsyncClient') as mock_client:
mock_response = AsyncMock()
mock_response.status_code = 404
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
"Not Found", request=..., response=mock_response
)
mock_client.return_value.get = AsyncMock(return_value=mock_response)
client = LeagueApiClient("pd")
with pytest.raises(httpx.HTTPStatusError):
await client.get_player(999999)
```
### Integration Tests with Real API
```python
@pytest.mark.integration
@pytest.mark.asyncio
async def test_real_sba_api():
"""Test with real SBA API (requires network)"""
async with LeagueApiClient("sba") as client:
player = await client.get_player(12288)
# Verify structure
assert player.id == 12288
assert isinstance(player.name, str)
assert isinstance(player.team, SbaTeamApi)
assert len(player.team.lname) > 0
@pytest.mark.integration
@pytest.mark.asyncio
async def test_real_pd_api_with_ratings():
"""Test with real PD API (requires network)"""
async with LeagueApiClient("pd") as client:
player = await client.get_player(10633)
batting = await client.get_batting_ratings(10633)
# Verify structure
assert player.player_id == 10633
assert batting.count == 2 # vs L and vs R
assert len(batting.ratings) == 2
```
---
## Performance Considerations
### Request Timing
```python
import time
class LeagueApiClient:
async def get_player(self, player_id: int):
start = time.time()
# ... fetch logic ...
elapsed = time.time() - start
logger.info(f"Player fetch took {elapsed:.2f}s")
return player
```
### Connection Pooling
httpx automatically handles connection pooling:
- Reuses connections for multiple requests
- Default pool size: 10 connections
- Customize if needed:
```python
limits = httpx.Limits(max_keepalive_connections=20, max_connections=50)
self.client = httpx.AsyncClient(
base_url=self.base_url,
timeout=10.0,
limits=limits
)
```
---
## Configuration
Add API base URLs to league configs:
```python
# app/config/league_configs.py
class SbaConfig(BaseGameConfig):
league_id: str = "sba"
def get_api_base_url(self) -> str:
return "https://api.sba.manticorum.com/"
class PdConfig(BaseGameConfig):
league_id: str = "pd"
def get_api_base_url(self) -> str:
return "https://pd.manticorum.com/"
```
---
**Next**: See [testing-strategy.md](./testing-strategy.md) for comprehensive test plans

View File

@ -0,0 +1,633 @@
# PD API Models Specification
**Purpose**: Pydantic models that exactly match Paper Dynasty API responses
**File**: `backend/app/models/api_models.py` (PD section)
---
## Overview
PD player data comes from **3 separate API calls**:
1. `/api/v2/players/:player_id` - Basic player info
2. `/api/v2/battingcardratings/player/:player_id` - Batting outcome probabilities
3. `/api/v2/pitchingcardratings/player/:player_id` - Pitching outcome probabilities
These models match the API responses **exactly** for type-safe deserialization.
---
## Base URL
```python
PD_API_BASE_URL = "https://pd.manticorum.com/"
```
---
## Model Hierarchy
```
PdPlayerApi (basic player data)
├─ cardset: PdCardsetApi
├─ rarity: PdRarityApi
└─ mlbplayer: PdMlbPlayerApi
PdBattingRatingsResponseApi (ratings response)
└─ ratings: List[PdBattingRatingApi]
└─ battingcard: PdBattingCardApi
└─ player: PdPlayerApi (nested, same structure)
PdPitchingRatingsResponseApi (ratings response)
└─ ratings: List[PdPitchingRatingApi]
└─ pitchingcard: PdPitchingCardApi
└─ player: PdPlayerApi (nested, same structure)
```
---
## Nested Models (Shared)
### PdCardsetApi
**JSON Example**:
```json
{
"id": 20,
"name": "1998 Season",
"description": "Cards based on the 1998 MLB season",
"event": null,
"for_purchase": true,
"total_cards": 1,
"in_packs": true,
"ranked_legal": true
}
```
**Pydantic Model**:
```python
class PdCardsetApi(BaseModel):
"""PD Cardset (nested in player response)"""
id: int
name: str
description: str
event: Optional[str] = None
for_purchase: bool
total_cards: int
in_packs: bool
ranked_legal: bool
```
### PdRarityApi
**JSON Example**:
```json
{
"id": 1,
"value": 5,
"name": "MVP",
"color": "56f1fa"
}
```
**Pydantic Model**:
```python
class PdRarityApi(BaseModel):
"""PD Rarity tier (nested in player response)"""
id: int
value: int # 0-5 (Replacement to MVP)
name: str # "Replacement", "Bench", "Starter", "All-Star", "Superstar", "MVP"
color: str # Hex color without #
```
### PdMlbPlayerApi
**JSON Example**:
```json
{
"id": 4180,
"first_name": "Matt",
"last_name": "Karchner",
"key_fangraphs": 1006697,
"key_bbref": "karchma01",
"key_retro": "karcm001",
"key_mlbam": 116840,
"offense_col": 2
}
```
**Pydantic Model**:
```python
class PdMlbPlayerApi(BaseModel):
"""Real MLB player data (nested in player response)"""
id: int
first_name: str
last_name: str
key_fangraphs: int
key_bbref: str
key_retro: str
key_mlbam: int
offense_col: int # 1 or 2 (which offense column on card)
```
### PdPaperdexEntryApi
**JSON Example**:
```json
{
"id": 40108,
"team": 69,
"player": 11223,
"created": 1742675422723
}
```
**Pydantic Model**:
```python
class PdPaperdexEntryApi(BaseModel):
"""Single paperdex entry (ownership record)"""
id: int
team: int
player: int
created: int # Timestamp
```
### PdPaperdexApi
**JSON Example**:
```json
{
"count": 1,
"paperdex": [
{
"id": 40108,
"team": 69,
"player": 11223,
"created": 1742675422723
}
]
}
```
**Pydantic Model**:
```python
class PdPaperdexApi(BaseModel):
"""Paperdex collection data (nested in player response)"""
count: int
paperdex: List[PdPaperdexEntryApi]
```
---
## Player Model
### PdPlayerApi
**API Endpoint**: `GET /api/v2/players/:player_id?csv=false`
**JSON Example**:
```json
{
"player_id": 11223,
"p_name": "Matt Karchner",
"cost": 1266,
"image": "https://pd.manticorum.com/api/v2/players/11223/pitchingcard?d=2025-4-14",
"image2": null,
"mlbclub": "Chicago White Sox",
"franchise": "Chicago White Sox",
"cardset": {
"id": 20,
"name": "1998 Season",
"description": "Cards based on the 1998 MLB season",
"event": null,
"for_purchase": true,
"total_cards": 1,
"in_packs": true,
"ranked_legal": true
},
"set_num": 1006697,
"rarity": {
"id": 1,
"value": 5,
"name": "MVP",
"color": "56f1fa"
},
"pos_1": "RP",
"pos_2": "CP",
"pos_3": null,
"pos_4": null,
"pos_5": null,
"pos_6": null,
"pos_7": null,
"pos_8": null,
"headshot": "https://www.baseball-reference.com/req/202412180/images/headshots/5/506ce471_sabr.jpg",
"vanity_card": null,
"strat_code": null,
"bbref_id": "karchma01",
"fangr_id": "1006697",
"description": "1998 Season",
"quantity": 999,
"mlbplayer": {
"id": 4180,
"first_name": "Matt",
"last_name": "Karchner",
"key_fangraphs": 1006697,
"key_bbref": "karchma01",
"key_retro": "karcm001",
"key_mlbam": 116840,
"offense_col": 2
},
"paperdex": {
"count": 1,
"paperdex": [
{
"id": 40108,
"team": 69,
"player": 11223,
"created": 1742675422723
}
]
}
}
```
**Pydantic Model**:
```python
class PdPlayerApi(BaseModel):
"""PD Player API response"""
player_id: int
p_name: str
cost: int
image: str
image2: Optional[str] = None
mlbclub: str
franchise: str
cardset: PdCardsetApi
set_num: int
rarity: PdRarityApi
# Positions (up to 8)
pos_1: Optional[str] = None
pos_2: Optional[str] = None
pos_3: Optional[str] = None
pos_4: Optional[str] = None
pos_5: Optional[str] = None
pos_6: Optional[str] = None
pos_7: Optional[str] = None
pos_8: Optional[str] = None
headshot: Optional[str] = None
vanity_card: Optional[str] = None
strat_code: Optional[str] = None
bbref_id: str
fangr_id: str
description: str
quantity: int
mlbplayer: PdMlbPlayerApi
paperdex: PdPaperdexApi
```
---
## Batting Card Models
### PdBattingCardApi
**JSON Example** (nested in ratings response):
```json
{
"id": 4871,
"player": { ... }, // Full PdPlayerApi structure
"variant": 0,
"steal_low": 8,
"steal_high": 11,
"steal_auto": true,
"steal_jump": 0.25,
"bunting": "C",
"hit_and_run": "D",
"running": 13,
"offense_col": 1,
"hand": "R"
}
```
**Pydantic Model**:
```python
class PdBattingCardApi(BaseModel):
"""PD Batting Card (nested in rating response)"""
id: int
player: PdPlayerApi
variant: int
steal_low: int
steal_high: int
steal_auto: bool
steal_jump: float
bunting: str # A, B, C, D, E grades
hit_and_run: str # A, B, C, D, E grades
running: int
offense_col: int # 1 or 2
hand: str # R, L, S (switch)
```
### PdBattingRatingApi
**JSON Example** (single rating, vs L or vs R):
```json
{
"id": 9703,
"battingcard": { ... }, // PdBattingCardApi
"vs_hand": "L",
"pull_rate": 0.29379,
"center_rate": 0.41243,
"slap_rate": 0.29378,
"homerun": 0.0,
"bp_homerun": 2.0,
"triple": 1.4,
"double_three": 0.0,
"double_two": 5.1,
"double_pull": 5.1,
"single_two": 3.5,
"single_one": 4.5,
"single_center": 1.35,
"bp_single": 5.0,
"hbp": 2.0,
"walk": 18.25,
"strikeout": 9.75,
"lineout": 9.0,
"popout": 16.0,
"flyout_a": 0.0,
"flyout_bq": 1.65,
"flyout_lf_b": 1.9,
"flyout_rf_b": 2.0,
"groundout_a": 7.0,
"groundout_b": 10.5,
"groundout_c": 2.0,
"avg": 0.2263888888888889,
"obp": 0.41388888888888886,
"slg": 0.37453703703703706
}
```
**Pydantic Model**:
```python
class PdBattingRatingApi(BaseModel):
"""Single batting rating (vs L or vs R pitcher)"""
id: int
battingcard: PdBattingCardApi
vs_hand: str # "L" or "R" (vs LHP or RHP)
# Hit distribution
pull_rate: float
center_rate: float
slap_rate: float
# Outcome probabilities (these are the critical fields!)
homerun: float
bp_homerun: float # Ballpark homerun
triple: float
double_three: float
double_two: float
double_pull: float
single_two: float
single_one: float
single_center: float
bp_single: float # Ballpark single
hbp: float # Hit by pitch
walk: float
strikeout: float
lineout: float
popout: float
flyout_a: float
flyout_bq: float
flyout_lf_b: float
flyout_rf_b: float
groundout_a: float
groundout_b: float
groundout_c: float
# Summary stats
avg: float
obp: float
slg: float
```
### PdBattingRatingsResponseApi
**API Endpoint**: `GET /api/v2/battingcardratings/player/:player_id?short_output=false`
**JSON Example**:
```json
{
"count": 2,
"ratings": [
{ ... }, // vs L rating
{ ... } // vs R rating
]
}
```
**Pydantic Model**:
```python
class PdBattingRatingsResponseApi(BaseModel):
"""Full batting ratings API response"""
count: int // Should always be 2 (vs L and vs R)
ratings: List[PdBattingRatingApi]
```
---
## Pitching Card Models
### PdPitchingCardApi
**JSON Example** (nested in ratings response):
```json
{
"id": 4049,
"player": { ... }, // Full PdPlayerApi structure
"variant": 0,
"balk": 0,
"wild_pitch": 20,
"hold": 9,
"starter_rating": 1,
"relief_rating": 2,
"closer_rating": null,
"batting": "#1WR-C",
"offense_col": 1,
"hand": "R"
}
```
**Pydantic Model**:
```python
class PdPitchingCardApi(BaseModel):
"""PD Pitching Card (nested in rating response)"""
id: int
player: PdPlayerApi
variant: int
balk: int
wild_pitch: int # d20 range (e.g., 20 means on roll of 20)
hold: int
starter_rating: int # 1-5
relief_rating: int # 1-5
closer_rating: Optional[int] = None # 1-5 or null
batting: str # Pitcher batting ability (e.g., "#1WR-C")
offense_col: int # 1 or 2
hand: str # R, L
```
### PdPitchingRatingApi
**JSON Example** (single rating, vs L or vs R):
```json
{
"id": 8097,
"pitchingcard": { ... }, // PdPitchingCardApi
"vs_hand": "L",
"homerun": 2.6,
"bp_homerun": 6.0,
"triple": 2.1,
"double_three": 0.0,
"double_two": 7.1,
"double_cf": 0.0,
"single_two": 1.0,
"single_one": 1.0,
"single_center": 0.0,
"bp_single": 5.0,
"hbp": 6.0,
"walk": 17.6,
"strikeout": 11.4,
"flyout_lf_b": 0.0,
"flyout_cf_b": 7.75,
"flyout_rf_b": 3.6,
"groundout_a": 1.75,
"groundout_b": 6.1,
"xcheck_p": 1.0,
"xcheck_c": 3.0,
"xcheck_1b": 2.0,
"xcheck_2b": 6.0,
"xcheck_3b": 3.0,
"xcheck_ss": 7.0,
"xcheck_lf": 2.0,
"xcheck_cf": 3.0,
"xcheck_rf": 2.0,
"avg": 0.17870370370370367,
"obp": 0.3972222222222222,
"slg": 0.4388888888888889
}
```
**Pydantic Model**:
```python
class PdPitchingRatingApi(BaseModel):
"""Single pitching rating (vs L or vs R batter)"""
id: int
pitchingcard: PdPitchingCardApi
vs_hand: str # "L" or "R" (vs LHB or RHB)
# Outcome probabilities (these are the critical fields!)
homerun: float
bp_homerun: float
triple: float
double_three: float
double_two: float
double_cf: float # Double to center field
single_two: float
single_one: float
single_center: float
bp_single: float
hbp: float
walk: float
strikeout: float
flyout_lf_b: float
flyout_cf_b: float
flyout_rf_b: float
groundout_a: float
groundout_b: float
# X-check (fielding checks by position)
xcheck_p: float
xcheck_c: float
xcheck_1b: float
xcheck_2b: float
xcheck_3b: float
xcheck_ss: float
xcheck_lf: float
xcheck_cf: float
xcheck_rf: float
# Summary stats
avg: float
obp: float
slg: float
```
### PdPitchingRatingsResponseApi
**API Endpoint**: `GET /api/v2/pitchingcardratings/player/:player_id?short_output=false`
**JSON Example**:
```json
{
"count": 2,
"ratings": [
{ ... }, // vs L rating
{ ... } // vs R rating
]
}
```
**Pydantic Model**:
```python
class PdPitchingRatingsResponseApi(BaseModel):
"""Full pitching ratings API response"""
count: int // Should always be 2 (vs L and vs R)
ratings: List[PdPitchingRatingApi]
```
---
## Usage Example
```python
import httpx
from app.models.api_models import (
PdPlayerApi,
PdBattingRatingsResponseApi,
PdPitchingRatingsResponseApi
)
# Fetch and deserialize player
async with httpx.AsyncClient() as client:
# Player data
response = await client.get(
"https://pd.manticorum.com/api/v2/players/11223?csv=false"
)
player = PdPlayerApi(**response.json())
# Batting ratings
response = await client.get(
"https://pd.manticorum.com/api/v2/battingcardratings/player/11223?short_output=false"
)
batting_ratings = PdBattingRatingsResponseApi(**response.json())
# Pitching ratings
response = await client.get(
"https://pd.manticorum.com/api/v2/pitchingcardratings/player/11223?short_output=false"
)
pitching_ratings = PdPitchingRatingsResponseApi(**response.json())
```
---
## Notes
1. **Ratings are per handedness**: Always 2 ratings (vs L and vs R)
2. **Outcome probabilities**: Floats represent percentage chance (e.g., 2.6 = 2.6%)
3. **All probabilities should sum to 100**: Use for validation in tests
4. **Player nested in cards**: Ratings responses contain full player data again
5. **Forward references**: May need `from __future__ import annotations` for circular refs
---
**Next**: See [api-models-sba.md](./api-models-sba.md) for SBA API models

View File

@ -0,0 +1,312 @@
# SBA API Models Specification
**Purpose**: Pydantic models that exactly match SBA API responses
**File**: `backend/app/models/api_models.py` (SBA section)
---
## Overview
SBA player data comes from **1 API call**:
- `/players/:player_id?short_output=false` - Player with nested team data
Much simpler than PD - no separate ratings calls needed.
---
## Base URL
```python
SBA_API_BASE_URL = "https://api.sba.manticorum.com/"
```
---
## Model Hierarchy
```
SbaPlayerApi (player data)
└─ team: SbaTeamApi
├─ manager1: SbaManagerApi
├─ manager2: Optional[SbaManagerApi]
└─ division: SbaDivisionApi
```
---
## Nested Models
### SbaManagerApi
**JSON Example**:
```json
{
"id": 3,
"name": "Cal",
"image": null,
"headline": null,
"bio": null
}
```
**Pydantic Model**:
```python
class SbaManagerApi(BaseModel):
"""SBA Manager (nested in team response)"""
id: int
name: str
image: Optional[str] = None
headline: Optional[str] = None
bio: Optional[str] = None
```
### SbaDivisionApi
**JSON Example**:
```json
{
"id": 41,
"division_name": "Big Chungus",
"division_abbrev": "BBC",
"league_name": "SBa",
"league_abbrev": "SBa",
"season": 12
}
```
**Pydantic Model**:
```python
class SbaDivisionApi(BaseModel):
"""SBA Division (nested in team response)"""
id: int
division_name: str
division_abbrev: str
league_name: str
league_abbrev: str
season: int
```
### SbaTeamApi
**JSON Example**:
```json
{
"id": 499,
"abbrev": "WV",
"sname": "Black Bears",
"lname": "West Virginia Black Bears",
"manager_legacy": null,
"division_legacy": null,
"gmid": "258104532423147520",
"gmid2": null,
"manager1": {
"id": 3,
"name": "Cal",
"image": null,
"headline": null,
"bio": null
},
"manager2": null,
"division": {
"id": 41,
"division_name": "Big Chungus",
"division_abbrev": "BBC",
"league_name": "SBa",
"league_abbrev": "SBa",
"season": 12
},
"mascot": null,
"stadium": "https://i.postimg.cc/rpRZ2NNF/wvpark.png",
"gsheet": null,
"thumbnail": "https://i.postimg.cc/HjDc8bBF/blackbears-transparent.png",
"color": "6699FF",
"dice_color": null,
"season": 12,
"auto_draft": null
}
```
**Pydantic Model**:
```python
class SbaTeamApi(BaseModel):
"""SBA Team (nested in player response)"""
id: int
abbrev: str
sname: str # Short name
lname: str # Long name
manager_legacy: Optional[str] = None
division_legacy: Optional[str] = None
gmid: str # Discord Guild/Manager ID
gmid2: Optional[str] = None
manager1: SbaManagerApi
manager2: Optional[SbaManagerApi] = None
division: SbaDivisionApi
mascot: Optional[str] = None
stadium: Optional[str] = None
gsheet: Optional[str] = None
thumbnail: Optional[str] = None
color: str # Hex color without #
dice_color: Optional[str] = None
season: int
auto_draft: Optional[bool] = None
```
---
## Player Model
### SbaPlayerApi
**API Endpoint**: `GET /players/:player_id?short_output=false`
**JSON Example**:
```json
{
"id": 12288,
"name": "Ronald Acuna Jr",
"wara": 0.0,
"image": "https://sba-cards-2024.s3.us-east-1.amazonaws.com/2024-cards/ronald-acuna-jr.png",
"image2": null,
"team": {
"id": 499,
"abbrev": "WV",
"sname": "Black Bears",
"lname": "West Virginia Black Bears",
"manager_legacy": null,
"division_legacy": null,
"gmid": "258104532423147520",
"gmid2": null,
"manager1": {
"id": 3,
"name": "Cal",
"image": null,
"headline": null,
"bio": null
},
"manager2": null,
"division": {
"id": 41,
"division_name": "Big Chungus",
"division_abbrev": "BBC",
"league_name": "SBa",
"league_abbrev": "SBa",
"season": 12
},
"mascot": null,
"stadium": "https://i.postimg.cc/rpRZ2NNF/wvpark.png",
"gsheet": null,
"thumbnail": "https://i.postimg.cc/HjDc8bBF/blackbears-transparent.png",
"color": "6699FF",
"dice_color": null,
"season": 12,
"auto_draft": null
},
"season": 12,
"pitcher_injury": null,
"pos_1": "RF",
"pos_2": null,
"pos_3": null,
"pos_4": null,
"pos_5": null,
"pos_6": null,
"pos_7": null,
"pos_8": null,
"last_game": null,
"last_game2": null,
"il_return": null,
"demotion_week": 16,
"headshot": null,
"vanity_card": null,
"strat_code": "Acuna,R",
"bbref_id": "acunaro01",
"injury_rating": "5p30",
"sbaplayer": null
}
```
**Pydantic Model**:
```python
class SbaPlayerApi(BaseModel):
"""SBA Player API response"""
id: int
name: str
wara: float # Wins Above Replacement Average
image: str # Card image URL
image2: Optional[str] = None
team: SbaTeamApi
season: int
# Positions (up to 8)
pos_1: Optional[str] = None
pos_2: Optional[str] = None
pos_3: Optional[str] = None
pos_4: Optional[str] = None
pos_5: Optional[str] = None
pos_6: Optional[str] = None
pos_7: Optional[str] = None
pos_8: Optional[str] = None
# Injury/status tracking
pitcher_injury: Optional[str] = None
last_game: Optional[str] = None
last_game2: Optional[str] = None
il_return: Optional[str] = None # Injured list return date
demotion_week: Optional[int] = None
# Additional metadata
headshot: Optional[str] = None
vanity_card: Optional[str] = None
strat_code: Optional[str] = None # Strat-O-Matic code
bbref_id: str # Baseball Reference ID
injury_rating: Optional[str] = None # e.g., "5p30"
sbaplayer: Optional[int] = None # Link to another player record?
```
---
## Usage Example
```python
import httpx
from app.models.api_models import SbaPlayerApi
# Fetch and deserialize player
async with httpx.AsyncClient() as client:
response = await client.get(
"https://api.sba.manticorum.com/players/12288?short_output=false"
)
player = SbaPlayerApi(**response.json())
# Access nested data with full type safety
print(f"{player.name} plays for {player.team.lname}")
print(f"Manager: {player.team.manager1.name}")
print(f"Division: {player.team.division.division_name}")
```
---
## Differences from PD
| Aspect | SBA | PD |
|--------|-----|-----|
| **API Calls** | 1 call | 3 calls (player + batting + pitching) |
| **Primary Key** | `id` | `player_id` |
| **Name Field** | `name` | `p_name` |
| **Team Data** | Full nested object | Just `mlbclub` string |
| **Ratings** | None (simplified gameplay) | Detailed outcome probabilities |
| **Complexity** | Simple | Complex |
---
## Notes
1. **Single API call**: Much simpler than PD - all data in one response
2. **Rich team data**: Full team/manager/division hierarchy included
3. **No ratings**: SBA uses simplified result charts, not detailed probabilities
4. **Position handling**: Same pos_1-8 pattern as PD
5. **Injury tracking**: Additional fields for roster management (IL, demotion)
---
**Next**: See [game-models.md](./game-models.md) for game-optimized player models

View File

@ -0,0 +1,493 @@
# Game Models Specification
**Purpose**: Game-optimized player models used by GameEngine and PlayResolver
**File**: `backend/app/models/player_models.py`
---
## Overview
Game models contain **only** the fields needed for gameplay:
- Player identification (id, name)
- Positions (normalized list)
- Image URL (for UI)
- **PD only**: Outcome probabilities (batting/pitching ratings)
Everything else from the API (team hierarchy, metadata, etc.) is discarded.
---
## Design Principles
1. **Minimal Data**: Only fields used during gameplay
2. **Fast Serialization**: Small models for WebSocket broadcasts
3. **No Nested Objects**: Flatten complex structures (team → team_name)
4. **League Polymorphism**: Common base class with league-specific subclasses
---
## Base Player Model
### BasePlayer (Abstract)
**Purpose**: Common interface for all players
```python
from abc import ABC, abstractmethod
from typing import List
from pydantic import BaseModel
class BasePlayer(BaseModel, ABC):
"""
Abstract base player model for gameplay
All players have basic identification regardless of league.
League-specific data in subclasses.
"""
# Core identification
player_id: int # Standardized field name (maps from 'id' or 'player_id')
name: str
# Gameplay essentials
positions: List[str] # Extracted from pos_1-8, e.g., ["RF", "CF"]
image_url: str # Card image
@abstractmethod
def get_primary_position(self) -> str:
"""Get primary position (first in list)"""
pass
@abstractmethod
def can_play_position(self, position: str) -> bool:
"""Check if player can play position"""
pass
model_config = {
"frozen": False # Allow mutation during game
}
```
---
## SBA Game Model
### SbaPlayer
**Purpose**: Simplified SBA player for gameplay
**What's Included**:
- Basic player info
- Positions
- Team name (flattened from nested team object)
- Image URL
**What's Excluded**:
- Full team object (manager, division, stadium, etc.)
- Injury data (not needed during gameplay)
- Season/WARA stats
- Baseball Reference IDs
```python
class SbaPlayer(BasePlayer):
"""
SBA player model for gameplay
Simplified from SbaPlayerApi - only gameplay essentials.
"""
# Additional SBA-specific fields
team_name: str # Flattened from team.lname
team_abbrev: str # Flattened from team.abbrev
strat_code: Optional[str] = None # For reference
def get_primary_position(self) -> str:
"""Get primary position"""
return self.positions[0] if self.positions else "UTIL"
def can_play_position(self, position: str) -> bool:
"""Check if player can play position"""
return position in self.positions
def get_display_name(self) -> str:
"""Get display name for UI"""
primary_pos = self.get_primary_position()
return f"{self.name} ({primary_pos})"
model_config = {
"json_schema_extra": {
"example": {
"player_id": 12288,
"name": "Ronald Acuna Jr",
"positions": ["RF"],
"image_url": "https://sba-cards-2024.s3.us-east-1.amazonaws.com/2024-cards/ronald-acuna-jr.png",
"team_name": "West Virginia Black Bears",
"team_abbrev": "WV",
"strat_code": "Acuna,R"
}
}
}
```
---
## PD Game Models
### PdBattingRating
**Purpose**: Batting outcome probabilities for one handedness (vs L or vs R)
**What's Included**:
- Outcome probabilities (homerun, strikeout, walk, etc.)
- Summary stats (avg, obp, slg) for quick reference
**What's Excluded**:
- Full battingcard object (card metadata not needed)
- ID fields
- Player reference (already have player)
```python
class PdBattingRating(BaseModel):
"""
Batting outcome probabilities for one handedness
Extracted from PdBattingRatingApi - just the probabilities needed
for play resolution.
"""
vs_hand: str # "L" or "R" (vs LHP or RHP)
# Hit outcomes (probabilities)
homerun: float
triple: float
double: float # Sum of double_three, double_two, double_pull
single: float # Sum of single_two, single_one, single_center
# Plate discipline
walk: float
hbp: float
strikeout: float
# Out types
flyout: float # Sum of all flyout types
lineout: float
popout: float
groundout: float # Sum of groundout_a, groundout_b, groundout_c
# Ballpark outcomes
bp_homerun: float
bp_single: float
# Summary stats (for quick checks)
avg: float
obp: float
slg: float
def total_probability(self) -> float:
"""Calculate total probability (should be ~100.0)"""
return (
self.homerun + self.triple + self.double + self.single +
self.walk + self.hbp + self.strikeout +
self.flyout + self.lineout + self.popout + self.groundout +
self.bp_homerun + self.bp_single
)
```
### PdPitchingRating
**Purpose**: Pitching outcome probabilities for one handedness (vs L or vs R)
```python
class PdPitchingRating(BaseModel):
"""
Pitching outcome probabilities for one handedness
Extracted from PdPitchingRatingApi - just the probabilities needed
for play resolution.
"""
vs_hand: str # "L" or "R" (vs LHB or RHB)
# Hit outcomes
homerun: float
triple: float
double: float # Sum of double_three, double_two, double_cf
single: float # Sum of single_two, single_one, single_center
# Plate discipline
walk: float
hbp: float
strikeout: float
# Out types
flyout: float # Sum of flyout types
groundout: float # Sum of groundout_a, groundout_b
# Ballpark outcomes
bp_homerun: float
bp_single: float
# X-check probabilities (fielding checks)
xcheck: dict[str, float] # Position → probability
# Summary stats
avg: float
obp: float
slg: float
def total_probability(self) -> float:
"""Calculate total probability (should be ~100.0)"""
return (
self.homerun + self.triple + self.double + self.single +
self.walk + self.hbp + self.strikeout +
self.flyout + self.groundout +
self.bp_homerun + self.bp_single
)
```
### PdPlayer
**Purpose**: PD player with attached ratings for gameplay
**What's Included**:
- Basic player info
- Batting ratings (vs L and vs R)
- Pitching ratings (vs L and vs R) if pitcher
- Cardset name (for eligibility checks)
**What's Excluded**:
- Full cardset object (just need name/id)
- Rarity (not used in gameplay)
- MLB player data (not used in gameplay)
- Paperdex (ownership tracking, not gameplay)
```python
class PdPlayer(BasePlayer):
"""
PD player model for gameplay
Includes outcome probability ratings from batting/pitching cards.
Much more complex than SBA due to detailed simulation model.
"""
# Basic info
team_name: str # From mlbclub
cardset_id: int
cardset_name: str # From cardset.name
# Player type
is_pitcher: bool # Determines which ratings to use
hand: str # R, L, S (batter handedness or pitcher throws)
# Batting ratings (always present, even for pitchers)
batting_vs_lhp: Optional[PdBattingRating] = None
batting_vs_rhp: Optional[PdBattingRating] = None
# Pitching ratings (only for pitchers)
pitching_vs_lhb: Optional[PdPitchingRating] = None
pitching_vs_rhb: Optional[PdPitchingRating] = None
# Baserunning (from batting card)
steal_rating: Optional[str] = None # e.g., "8-11" (steal_low-steal_high)
running_speed: Optional[int] = None # 1-20 scale
# Pitcher specific
wild_pitch_range: Optional[int] = None # d20 range (e.g., 20)
def get_primary_position(self) -> str:
"""Get primary position"""
return self.positions[0] if self.positions else "UTIL"
def can_play_position(self, position: str) -> bool:
"""Check if player can play position"""
return position in self.positions
def get_batting_rating(self, vs_hand: str) -> Optional[PdBattingRating]:
"""Get batting rating vs pitcher hand"""
if vs_hand == "L":
return self.batting_vs_lhp
elif vs_hand == "R":
return self.batting_vs_rhp
return None
def get_pitching_rating(self, vs_hand: str) -> Optional[PdPitchingRating]:
"""Get pitching rating vs batter hand"""
if not self.is_pitcher:
return None
if vs_hand == "L":
return self.pitching_vs_lhb
elif vs_hand == "R":
return self.pitching_vs_rhb
return None
def get_display_name(self) -> str:
"""Get display name with handedness indicator"""
hand_symbol = {
"R": "⟩", # Right-handed arrow
"L": "⟨", # Left-handed arrow
"S": "⟨⟩" # Switch hitter
}.get(self.hand, "")
primary_pos = self.get_primary_position()
return f"{self.name} {hand_symbol} ({primary_pos})"
model_config = {
"json_schema_extra": {
"example": {
"player_id": 10633,
"name": "Chuck Knoblauch",
"positions": ["2B"],
"image_url": "https://pd.manticorum.com/api/v2/players/10633/battingcard?d=2025-4-14",
"team_name": "New York Yankees",
"cardset_id": 20,
"cardset_name": "1998 Season",
"is_pitcher": False,
"hand": "R",
"batting_vs_lhp": {
"vs_hand": "L",
"homerun": 0.0,
"triple": 1.4,
"double": 10.2,
"single": 9.3,
"walk": 18.25,
"hbp": 2.0,
"strikeout": 9.75,
"flyout": 3.55,
"lineout": 9.0,
"popout": 16.0,
"groundout": 19.5,
"bp_homerun": 2.0,
"bp_single": 5.0,
"avg": 0.226,
"obp": 0.414,
"slg": 0.375
},
"batting_vs_rhp": {
"vs_hand": "R",
"homerun": 1.05,
"triple": 1.2,
"double": 7.0,
"single": 13.1,
"walk": 12.1,
"hbp": 3.0,
"strikeout": 9.9,
"flyout": 3.15,
"lineout": 11.0,
"popout": 13.0,
"groundout": 23.6,
"bp_homerun": 3.0,
"bp_single": 5.0,
"avg": 0.244,
"obp": 0.384,
"slg": 0.402
},
"steal_rating": "8-11",
"running_speed": 13
}
}
}
```
---
## Field Selection Rationale
### Why These Fields?
**Included in Game Models**:
| Field | Why Needed |
|-------|------------|
| `player_id` | Primary key for references |
| `name` | Display to users |
| `positions` | Position eligibility validation |
| `image_url` | Display card image |
| `team_name` | Display team affiliation |
| **PD**: `ratings` | **Critical** - drive play resolution |
| **PD**: `hand` | Determines which rating to use |
| **PD**: `is_pitcher` | Determines card type |
**Excluded from Game Models**:
| Field | Why Not Needed |
|-------|----------------|
| Full team object | Only need team name string |
| Manager data | Not used in gameplay |
| Division data | Not used in gameplay |
| Rarity | Doesn't affect gameplay |
| Paperdex | Collection tracking, not gameplay |
| MLB IDs | External references, not gameplay |
| Injury data | Roster management, not gameplay |
---
## Size Comparison
### SBA Player
- **API Model**: ~50 fields (with nested team/manager/division)
- **Game Model**: ~7 fields
- **Size Reduction**: ~85%
### PD Player
- **API Models Combined**: ~150 fields (player + 2 batting ratings + 2 pitching ratings)
- **Game Model**: ~15 fields (with flattened ratings)
- **Size Reduction**: ~90%
---
## Usage in GameEngine
```python
from app.models.player_models import SbaPlayer, PdPlayer
# In PlayResolver
def resolve_play(
self,
batter: BasePlayer,
pitcher: BasePlayer,
...
) -> PlayResult:
# Type-safe access to league-specific features
if isinstance(batter, PdPlayer) and isinstance(pitcher, PdPlayer):
# Use detailed probability model
batting_rating = batter.get_batting_rating(pitcher.hand)
pitching_rating = pitcher.get_pitching_rating(batter.hand)
# Resolve using outcome probabilities
outcome = self._resolve_from_probabilities(batting_rating, pitching_rating)
elif isinstance(batter, SbaPlayer):
# Use simplified result chart
outcome = self._resolve_from_chart(dice_roll)
return outcome
```
---
## Validation
All game models include validation:
```python
# In tests
def test_pd_batting_rating_probabilities_sum_to_100():
"""Ensure all probabilities sum to ~100"""
rating = PdBattingRating(...)
total = rating.total_probability()
assert 99.0 <= total <= 101.0 # Allow small float rounding
def test_position_list_not_empty():
"""Players must have at least one position"""
with pytest.raises(ValidationError):
SbaPlayer(
player_id=1,
name="Test",
positions=[], # Invalid!
...
)
```
---
**Next**: See [mappers-and-factories.md](./mappers-and-factories.md) for API → Game transformations

View File

@ -0,0 +1,552 @@
# Mappers & Factories Specification
**Purpose**: Transform API models to game models and create players by league
**File**: `backend/app/models/player_models.py` (mapper section)
---
## Overview
Two key classes:
1. **PlayerMapper** - Transforms API models → Game models
2. **PlayerFactory** - Creates players by league_id (uses API client + mapper)
---
## PlayerMapper Class
### Purpose
Convert API response models to game-optimized models:
- Extract only essential fields
- Flatten nested structures
- Normalize position data (pos_1-8 → List[str])
- Combine multiple API responses (PD: player + ratings)
### Implementation
```python
class PlayerMapper:
"""
Maps API response models to game models
Handles league-specific transformation logic.
"""
@staticmethod
def extract_positions(
pos_1: Optional[str],
pos_2: Optional[str],
pos_3: Optional[str],
pos_4: Optional[str],
pos_5: Optional[str],
pos_6: Optional[str],
pos_7: Optional[str],
pos_8: Optional[str]
) -> List[str]:
"""
Extract position list from pos_1 through pos_8 fields
Args:
pos_1 through pos_8: Position fields from API
Returns:
List of non-null positions, e.g., ["RF", "CF", "DH"]
"""
positions = [pos_1, pos_2, pos_3, pos_4, pos_5, pos_6, pos_7, pos_8]
return [p for p in positions if p is not None]
@staticmethod
def map_sba_player(api_player: SbaPlayerApi) -> SbaPlayer:
"""
Map SBA API player to game model
Args:
api_player: Full API response with nested team data
Returns:
Simplified SbaPlayer for gameplay
"""
return SbaPlayer(
player_id=api_player.id,
name=api_player.name,
positions=PlayerMapper.extract_positions(
api_player.pos_1, api_player.pos_2, api_player.pos_3,
api_player.pos_4, api_player.pos_5, api_player.pos_6,
api_player.pos_7, api_player.pos_8
),
image_url=api_player.image,
team_name=api_player.team.lname,
team_abbrev=api_player.team.abbrev,
strat_code=api_player.strat_code
)
@staticmethod
def _flatten_batting_rating(api_rating: PdBattingRatingApi) -> PdBattingRating:
"""
Flatten PD batting rating API response to game model
Combines outcome fields (e.g., double_three + double_two + double_pull → double)
"""
return PdBattingRating(
vs_hand=api_rating.vs_hand,
homerun=api_rating.homerun,
triple=api_rating.triple,
double=(
api_rating.double_three +
api_rating.double_two +
api_rating.double_pull
),
single=(
api_rating.single_two +
api_rating.single_one +
api_rating.single_center
),
walk=api_rating.walk,
hbp=api_rating.hbp,
strikeout=api_rating.strikeout,
flyout=(
api_rating.flyout_a +
api_rating.flyout_bq +
api_rating.flyout_lf_b +
api_rating.flyout_rf_b
),
lineout=api_rating.lineout,
popout=api_rating.popout,
groundout=(
api_rating.groundout_a +
api_rating.groundout_b +
api_rating.groundout_c
),
bp_homerun=api_rating.bp_homerun,
bp_single=api_rating.bp_single,
avg=api_rating.avg,
obp=api_rating.obp,
slg=api_rating.slg
)
@staticmethod
def _flatten_pitching_rating(api_rating: PdPitchingRatingApi) -> PdPitchingRating:
"""
Flatten PD pitching rating API response to game model
Combines outcome fields and extracts xcheck dictionary
"""
return PdPitchingRating(
vs_hand=api_rating.vs_hand,
homerun=api_rating.homerun,
triple=api_rating.triple,
double=(
api_rating.double_three +
api_rating.double_two +
api_rating.double_cf
),
single=(
api_rating.single_two +
api_rating.single_one +
api_rating.single_center
),
walk=api_rating.walk,
hbp=api_rating.hbp,
strikeout=api_rating.strikeout,
flyout=(
api_rating.flyout_lf_b +
api_rating.flyout_cf_b +
api_rating.flyout_rf_b
),
groundout=(
api_rating.groundout_a +
api_rating.groundout_b
),
bp_homerun=api_rating.bp_homerun,
bp_single=api_rating.bp_single,
xcheck={
"P": api_rating.xcheck_p,
"C": api_rating.xcheck_c,
"1B": api_rating.xcheck_1b,
"2B": api_rating.xcheck_2b,
"3B": api_rating.xcheck_3b,
"SS": api_rating.xcheck_ss,
"LF": api_rating.xcheck_lf,
"CF": api_rating.xcheck_cf,
"RF": api_rating.xcheck_rf
},
avg=api_rating.avg,
obp=api_rating.obp,
slg=api_rating.slg
)
@staticmethod
def map_pd_player(
api_player: PdPlayerApi,
batting_ratings: Optional[PdBattingRatingsResponseApi] = None,
pitching_ratings: Optional[PdPitchingRatingsResponseApi] = None
) -> PdPlayer:
"""
Map PD API player + ratings to game model
Args:
api_player: Basic player data
batting_ratings: Batting ratings response (2 ratings: vs L and vs R)
pitching_ratings: Pitching ratings response (2 ratings: vs L and vs R)
Returns:
Complete PdPlayer for gameplay with ratings attached
"""
# Determine if pitcher (has pitching card)
is_pitcher = pitching_ratings is not None
# Extract batting ratings (all players have batting, even pitchers)
batting_vs_lhp = None
batting_vs_rhp = None
if batting_ratings:
for rating in batting_ratings.ratings:
flattened = PlayerMapper._flatten_batting_rating(rating)
if rating.vs_hand == "L":
batting_vs_lhp = flattened
elif rating.vs_hand == "R":
batting_vs_rhp = flattened
# Extract baserunning from batting card
batting_card = batting_ratings.ratings[0].battingcard
steal_rating = f"{batting_card.steal_low}-{batting_card.steal_high}"
running_speed = batting_card.running
hand = batting_card.hand
else:
steal_rating = None
running_speed = None
hand = "R" # Default
# Extract pitching ratings (only for pitchers)
pitching_vs_lhb = None
pitching_vs_rhb = None
wild_pitch_range = None
if pitching_ratings:
for rating in pitching_ratings.ratings:
flattened = PlayerMapper._flatten_pitching_rating(rating)
if rating.vs_hand == "L":
pitching_vs_lhb = flattened
elif rating.vs_hand == "R":
pitching_vs_rhb = flattened
# Extract pitcher-specific data
pitching_card = pitching_ratings.ratings[0].pitchingcard
wild_pitch_range = pitching_card.wild_pitch
hand = pitching_card.hand # Pitcher handedness
return PdPlayer(
player_id=api_player.player_id,
name=api_player.p_name,
positions=PlayerMapper.extract_positions(
api_player.pos_1, api_player.pos_2, api_player.pos_3,
api_player.pos_4, api_player.pos_5, api_player.pos_6,
api_player.pos_7, api_player.pos_8
),
image_url=api_player.image,
team_name=api_player.mlbclub,
cardset_id=api_player.cardset.id,
cardset_name=api_player.cardset.name,
is_pitcher=is_pitcher,
hand=hand,
batting_vs_lhp=batting_vs_lhp,
batting_vs_rhp=batting_vs_rhp,
pitching_vs_lhb=pitching_vs_lhb,
pitching_vs_rhb=pitching_vs_rhb,
steal_rating=steal_rating,
running_speed=running_speed,
wild_pitch_range=wild_pitch_range
)
```
---
## PlayerFactory Class
### Purpose
High-level interface to fetch and create players:
- Abstracts away API client details
- Handles league-specific logic (1 call for SBA, 3 for PD)
- Returns game models ready for use
### Implementation
```python
from app.data.api_client import LeagueApiClient
class PlayerFactory:
"""
Factory for creating player instances by league
Uses API client to fetch data and mapper to transform.
"""
@staticmethod
async def create_player(league_id: str, player_id: int) -> BasePlayer:
"""
Fetch player from API and create game model
Args:
league_id: 'sba' or 'pd'
player_id: Player ID in that league
Returns:
SbaPlayer or PdPlayer (via BasePlayer type)
Raises:
ValueError: If league_id is unknown
HTTPError: If API call fails
"""
if league_id == "sba":
return await PlayerFactory._create_sba_player(player_id)
elif league_id == "pd":
return await PlayerFactory._create_pd_player(player_id)
else:
raise ValueError(f"Unknown league: {league_id}")
@staticmethod
async def _create_sba_player(player_id: int) -> SbaPlayer:
"""
Fetch and create SBA player (1 API call)
Args:
player_id: SBA player ID
Returns:
SbaPlayer game model
"""
# Create API client
client = LeagueApiClient(league_id="sba")
try:
# Fetch player data
api_player = await client.get_player(player_id)
# Map to game model
game_player = PlayerMapper.map_sba_player(api_player)
return game_player
finally:
await client.close()
@staticmethod
async def _create_pd_player(player_id: int) -> PdPlayer:
"""
Fetch and create PD player (3 API calls)
Args:
player_id: PD player ID
Returns:
PdPlayer game model with ratings
"""
# Create API client
client = LeagueApiClient(league_id="pd")
try:
# Fetch player data (call 1)
api_player = await client.get_player(player_id)
# Fetch batting ratings (call 2)
batting_ratings = await client.get_batting_ratings(player_id)
# Fetch pitching ratings (call 3) - may fail if not a pitcher
try:
pitching_ratings = await client.get_pitching_ratings(player_id)
except Exception:
# Not a pitcher - that's okay
pitching_ratings = None
# Map to game model
game_player = PlayerMapper.map_pd_player(
api_player,
batting_ratings,
pitching_ratings
)
return game_player
finally:
await client.close()
@staticmethod
async def create_lineup(
league_id: str,
player_ids: List[int]
) -> List[BasePlayer]:
"""
Create multiple players for a lineup
Args:
league_id: 'sba' or 'pd'
player_ids: List of player IDs
Returns:
List of player game models
"""
players = []
for player_id in player_ids:
player = await PlayerFactory.create_player(league_id, player_id)
players.append(player)
return players
```
---
## Usage Examples
### Basic Usage
```python
from app.models.player_models import PlayerFactory
# Fetch SBA player
sba_player = await PlayerFactory.create_player("sba", 12288)
print(f"{sba_player.name} plays for {sba_player.team_name}")
print(f"Positions: {sba_player.positions}")
# Fetch PD player with ratings
pd_player = await PlayerFactory.create_player("pd", 10633)
print(f"{pd_player.name} - {pd_player.cardset_name}")
# Get batting rating vs RHP
rating = pd_player.get_batting_rating("R")
print(f"vs RHP - AVG: {rating.avg}, HR%: {rating.homerun}")
```
### In GameEngine
```python
# When creating a game, fetch lineups
away_player_ids = [101, 102, 103, 104, 105, 106, 107, 108, 109]
home_player_ids = [201, 202, 203, 204, 205, 206, 207, 208, 209]
away_lineup = await PlayerFactory.create_lineup("sba", away_player_ids)
home_lineup = await PlayerFactory.create_lineup("sba", home_player_ids)
# Store in game state
state.away_lineup = away_lineup
state.home_lineup = home_lineup
```
---
## Position Extraction Logic
### Input
```python
pos_1 = "RF"
pos_2 = "CF"
pos_3 = "DH"
pos_4 = None
pos_5 = None
pos_6 = None
pos_7 = None
pos_8 = None
```
### Output
```python
positions = ["RF", "CF", "DH"]
```
### Edge Cases
| Input | Output | Notes |
|-------|--------|-------|
| All None | `[]` | Invalid - should fail validation |
| `["P"]` | `["P"]` | Pitcher only |
| `["SP", "RP"]` | `["SP", "RP"]` | Starter/reliever |
| `["RF", None, "DH"]` | `["RF", "DH"]` | Skips None values |
---
## Testing Strategy
### Unit Tests
```python
def test_extract_positions_multiple():
"""Test extracting multiple positions"""
positions = PlayerMapper.extract_positions(
"RF", "CF", "DH", None, None, None, None, None
)
assert positions == ["RF", "CF", "DH"]
def test_map_sba_player():
"""Test SBA player mapping"""
api_player = SbaPlayerApi(...) # Full API response
game_player = PlayerMapper.map_sba_player(api_player)
assert game_player.player_id == api_player.id
assert game_player.name == api_player.name
assert game_player.team_name == api_player.team.lname
def test_map_pd_player_with_ratings():
"""Test PD player mapping with ratings"""
api_player = PdPlayerApi(...)
batting_ratings = PdBattingRatingsResponseApi(...)
pitching_ratings = None # Position player
game_player = PlayerMapper.map_pd_player(
api_player,
batting_ratings,
pitching_ratings
)
assert game_player.player_id == api_player.player_id
assert game_player.batting_vs_lhp is not None
assert game_player.batting_vs_rhp is not None
assert game_player.pitching_vs_lhb is None # Not a pitcher
def test_flattened_batting_rating_combines_outcomes():
"""Test that double/single/etc are properly combined"""
api_rating = PdBattingRatingApi(
double_three=1.0,
double_two=2.0,
double_pull=3.0,
...
)
game_rating = PlayerMapper._flatten_batting_rating(api_rating)
assert game_rating.double == 6.0 # 1.0 + 2.0 + 3.0
```
---
## Performance Considerations
### API Call Counts
| League | API Calls | Total Time (est) |
|--------|-----------|------------------|
| SBA | 1 | ~200ms |
| PD Position Player | 2 (player + batting) | ~400ms |
| PD Pitcher | 3 (player + batting + pitching) | ~600ms |
### Caching Strategy
For future optimization:
```python
# Add simple in-memory cache
_player_cache: Dict[Tuple[str, int], BasePlayer] = {}
async def create_player_cached(league_id: str, player_id: int) -> BasePlayer:
"""Create player with caching"""
cache_key = (league_id, player_id)
if cache_key in _player_cache:
return _player_cache[cache_key]
player = await PlayerFactory.create_player(league_id, player_id)
_player_cache[cache_key] = player
return player
```
---
**Next**: See [api-client.md](./api-client.md) for HTTP client implementation

View File

@ -0,0 +1,605 @@
# Testing Strategy for Player Models
**Purpose**: Comprehensive test plan for Week 6 player model implementation
---
## Overview
Testing will be organized into three layers:
1. **Unit Tests** - Individual components in isolation
2. **Integration Tests** - Components working together
3. **End-to-End Tests** - Full pipeline from API to game
Target: **90%+ code coverage** on all new modules
---
## Unit Tests
### 1. API Models (`test_api_models.py`)
**File**: `tests/unit/models/test_api_models.py`
**Coverage**: Pydantic model deserialization with real JSON samples
```python
import pytest
from pydantic import ValidationError
from app.models.api_models import (
SbaPlayerApi,
PdPlayerApi,
PdBattingRatingsResponseApi,
PdPitchingRatingsResponseApi
)
class TestSbaPlayerApi:
"""Test SBA API model deserialization"""
def test_deserialize_sba_player(self, sba_player_json):
"""Test valid SBA player response"""
player = SbaPlayerApi(**sba_player_json)
assert player.id == 12288
assert player.name == "Ronald Acuna Jr"
assert player.team.lname == "West Virginia Black Bears"
assert player.team.manager1.name == "Cal"
assert player.team.division.division_name == "Big Chungus"
def test_sba_player_positions(self, sba_player_json):
"""Test position fields"""
player = SbaPlayerApi(**sba_player_json)
assert player.pos_1 == "RF"
assert player.pos_2 is None
def test_sba_player_missing_required_field(self, sba_player_json):
"""Test validation fails on missing required field"""
del sba_player_json["name"]
with pytest.raises(ValidationError) as exc_info:
SbaPlayerApi(**sba_player_json)
assert "name" in str(exc_info.value)
class TestPdPlayerApi:
"""Test PD API model deserialization"""
def test_deserialize_pd_player(self, pd_player_json):
"""Test valid PD player response"""
player = PdPlayerApi(**pd_player_json)
assert player.player_id == 11223
assert player.p_name == "Matt Karchner"
assert player.cardset.name == "1998 Season"
assert player.rarity.name == "MVP"
assert player.mlbplayer.first_name == "Matt"
def test_pd_player_positions(self, pd_player_json):
"""Test position fields"""
player = PdPlayerApi(**pd_player_json)
assert player.pos_1 == "RP"
assert player.pos_2 == "CP"
assert player.pos_3 is None
class TestPdBattingRatingsApi:
"""Test PD batting ratings deserialization"""
def test_deserialize_batting_ratings(self, pd_batting_ratings_json):
"""Test valid batting ratings response"""
ratings = PdBattingRatingsResponseApi(**pd_batting_ratings_json)
assert ratings.count == 2
assert len(ratings.ratings) == 2
# Check vs L rating
vs_l = next(r for r in ratings.ratings if r.vs_hand == "L")
assert vs_l.homerun == 0.0
assert vs_l.walk == 18.25
assert vs_l.strikeout == 9.75
# Check vs R rating
vs_r = next(r for r in ratings.ratings if r.vs_hand == "R")
assert vs_r.homerun == 1.05
assert vs_r.walk == 12.1
def test_batting_probabilities_sum_to_100(self, pd_batting_ratings_json):
"""Test that all probabilities sum to ~100"""
ratings = PdBattingRatingsResponseApi(**pd_batting_ratings_json)
for rating in ratings.ratings:
total = (
rating.homerun + rating.bp_homerun +
rating.triple +
rating.double_three + rating.double_two + rating.double_pull +
rating.single_two + rating.single_one + rating.single_center +
rating.bp_single +
rating.hbp + rating.walk + rating.strikeout +
rating.lineout + rating.popout +
rating.flyout_a + rating.flyout_bq + rating.flyout_lf_b + rating.flyout_rf_b +
rating.groundout_a + rating.groundout_b + rating.groundout_c
)
# Allow small float rounding errors
assert 99.0 <= total <= 101.0, f"Total probability: {total}"
class TestPdPitchingRatingsApi:
"""Test PD pitching ratings deserialization"""
def test_deserialize_pitching_ratings(self, pd_pitching_ratings_json):
"""Test valid pitching ratings response"""
ratings = PdPitchingRatingsResponseApi(**pd_pitching_ratings_json)
assert ratings.count == 2
assert len(ratings.ratings) == 2
# Verify xcheck fields exist
vs_l = ratings.ratings[0]
assert vs_l.xcheck_p == 1.0
assert vs_l.xcheck_ss == 7.0
```
---
### 2. Game Models (`test_player_models.py`)
**File**: `tests/unit/models/test_player_models.py`
**Coverage**: Game model creation and methods
```python
import pytest
from pydantic import ValidationError
from app.models.player_models import (
SbaPlayer,
PdPlayer,
PdBattingRating,
PdPitchingRating
)
class TestSbaPlayer:
"""Test SBA game player model"""
def test_create_sba_player(self):
"""Test creating SBA player"""
player = SbaPlayer(
player_id=12288,
name="Ronald Acuna Jr",
positions=["RF", "CF"],
image_url="https://example.com/image.png",
team_name="West Virginia Black Bears",
team_abbrev="WV"
)
assert player.player_id == 12288
assert player.get_primary_position() == "RF"
assert player.can_play_position("CF")
assert not player.can_play_position("P")
def test_sba_player_display_name(self):
"""Test display name formatting"""
player = SbaPlayer(
player_id=1,
name="Test Player",
positions=["SS"],
image_url="https://example.com/image.png",
team_name="Test Team",
team_abbrev="TT"
)
assert player.get_display_name() == "Test Player (SS)"
def test_sba_player_requires_positions(self):
"""Test validation fails without positions"""
# Empty positions list should be invalid
with pytest.raises(ValidationError):
SbaPlayer(
player_id=1,
name="Test",
positions=[],
image_url="url",
team_name="Team",
team_abbrev="TM"
)
class TestPdPlayer:
"""Test PD game player model"""
def test_create_pd_player_with_batting(self):
"""Test creating PD position player"""
batting_vs_lhp = PdBattingRating(
vs_hand="L",
homerun=2.0,
triple=1.0,
double=10.0,
single=15.0,
walk=12.0,
hbp=2.0,
strikeout=10.0,
flyout=5.0,
lineout=8.0,
popout=15.0,
groundout=20.0,
bp_homerun=3.0,
bp_single=5.0,
avg=0.250,
obp=0.350,
slg=0.450
)
player = PdPlayer(
player_id=10633,
name="Chuck Knoblauch",
positions=["2B"],
image_url="https://example.com/image.png",
team_name="New York Yankees",
cardset_id=20,
cardset_name="1998 Season",
is_pitcher=False,
hand="R",
batting_vs_lhp=batting_vs_lhp,
batting_vs_rhp=None
)
assert player.player_id == 10633
assert not player.is_pitcher
assert player.get_batting_rating("L") == batting_vs_lhp
assert player.get_pitching_rating("L") is None
def test_pd_player_display_name_with_handedness(self):
"""Test display name includes handedness symbol"""
player = PdPlayer(
player_id=1,
name="Test Player",
positions=["2B"],
image_url="url",
team_name="Team",
cardset_id=1,
cardset_name="2021",
is_pitcher=False,
hand="L"
)
display = player.get_display_name()
assert "⟨" in display # Left-handed symbol
assert "(2B)" in display
class TestPdBattingRating:
"""Test PD batting rating model"""
def test_total_probability_calculation(self):
"""Test probability sum calculation"""
rating = PdBattingRating(
vs_hand="L",
homerun=2.0,
triple=1.0,
double=10.0,
single=15.0,
walk=12.0,
hbp=2.0,
strikeout=10.0,
flyout=5.0,
lineout=8.0,
popout=15.0,
groundout=20.0,
bp_homerun=3.0,
bp_single=5.0,
avg=0.250,
obp=0.350,
slg=0.450
)
total = rating.total_probability()
assert total == 108.0 # Sum of all outcomes
```
---
### 3. Mappers (`test_mappers.py`)
**File**: `tests/unit/models/test_mappers.py`
**Coverage**: API → Game model transformation
```python
import pytest
from app.models.api_models import SbaPlayerApi, PdPlayerApi
from app.models.player_models import PlayerMapper
class TestPlayerMapper:
"""Test player model mapping"""
def test_extract_positions_multiple(self):
"""Test extracting multiple positions"""
positions = PlayerMapper.extract_positions(
"RF", "CF", "DH", None, None, None, None, None
)
assert positions == ["RF", "CF", "DH"]
def test_extract_positions_single(self):
"""Test extracting single position"""
positions = PlayerMapper.extract_positions(
"P", None, None, None, None, None, None, None
)
assert positions == ["P"]
def test_extract_positions_all_none(self):
"""Test with all None (edge case)"""
positions = PlayerMapper.extract_positions(
None, None, None, None, None, None, None, None
)
assert positions == []
def test_map_sba_player(self, sba_player_api):
"""Test SBA player mapping"""
game_player = PlayerMapper.map_sba_player(sba_player_api)
assert game_player.player_id == sba_player_api.id
assert game_player.name == sba_player_api.name
assert game_player.team_name == sba_player_api.team.lname
assert game_player.team_abbrev == sba_player_api.team.abbrev
assert len(game_player.positions) > 0
def test_map_pd_player_position_player(
self,
pd_player_api,
pd_batting_ratings_api
):
"""Test PD position player mapping"""
game_player = PlayerMapper.map_pd_player(
pd_player_api,
pd_batting_ratings_api,
None # No pitching ratings
)
assert game_player.player_id == pd_player_api.player_id
assert game_player.name == pd_player_api.p_name
assert not game_player.is_pitcher
assert game_player.batting_vs_lhp is not None
assert game_player.batting_vs_rhp is not None
assert game_player.pitching_vs_lhb is None
def test_flatten_batting_rating_combines_doubles(
self,
pd_batting_rating_api
):
"""Test that double outcomes are combined"""
# Assume API has: double_three=1.0, double_two=2.0, double_pull=3.0
game_rating = PlayerMapper._flatten_batting_rating(pd_batting_rating_api)
# Should be combined to single "double" field
assert game_rating.double == 6.0 # 1.0 + 2.0 + 3.0
```
---
## Integration Tests
### 1. API Client (`test_api_client.py`)
**File**: `tests/integration/data/test_api_client.py`
**Coverage**: HTTP calls with mocked responses
```python
import pytest
from unittest.mock import AsyncMock, patch
from app.data.api_client import LeagueApiClient
@pytest.mark.integration
@pytest.mark.asyncio
class TestLeagueApiClient:
"""Test API client with mocked HTTP responses"""
async def test_fetch_sba_player_success(self, mock_httpx_response, sba_player_json):
"""Test successful SBA player fetch"""
with patch('httpx.AsyncClient') as mock_client:
mock_client.return_value.get = AsyncMock(
return_value=mock_httpx_response(sba_player_json)
)
async with LeagueApiClient("sba") as client:
player = await client.get_player(12288)
assert player.name == "Ronald Acuna Jr"
async def test_fetch_pd_player_with_ratings(
self,
mock_httpx_response,
pd_player_json,
pd_batting_ratings_json
):
"""Test fetching PD player with ratings"""
with patch('httpx.AsyncClient') as mock_client:
# Mock responses for 3 API calls
mock_client.return_value.get = AsyncMock(
side_effect=[
mock_httpx_response(pd_player_json),
mock_httpx_response(pd_batting_ratings_json),
httpx.HTTPStatusError("Not a pitcher")
]
)
async with LeagueApiClient("pd") as client:
player = await client.get_player(10633)
batting = await client.get_batting_ratings(10633)
with pytest.raises(httpx.HTTPStatusError):
await client.get_pitching_ratings(10633)
async def test_api_client_context_manager_closes(self):
"""Test that context manager closes client"""
client = LeagueApiClient("sba")
assert client.client is not None
async with client:
pass # Use context manager
# Client should be closed (aclose called)
# Verify by checking if subsequent calls fail
```
---
### 2. Full Pipeline (`test_player_pipeline.py`)
**File**: `tests/integration/test_player_pipeline.py`
**Coverage**: API → Mapper → Game Model
```python
import pytest
from app.models.player_models import PlayerFactory
@pytest.mark.integration
@pytest.mark.asyncio
class TestPlayerPipeline:
"""Test complete pipeline from API to game model"""
async def test_sba_player_full_pipeline(self, mock_sba_api):
"""Test fetching and mapping SBA player"""
player = await PlayerFactory.create_player("sba", 12288)
# Verify game model
assert player.player_id == 12288
assert player.name == "Ronald Acuna Jr"
assert "RF" in player.positions
assert player.can_play_position("RF")
async def test_pd_position_player_full_pipeline(self, mock_pd_api):
"""Test fetching and mapping PD position player"""
player = await PlayerFactory.create_player("pd", 10633)
# Verify game model
assert player.player_id == 10633
assert player.name == "Chuck Knoblauch"
assert not player.is_pitcher
# Verify ratings attached
assert player.batting_vs_lhp is not None
assert player.batting_vs_rhp is not None
assert player.pitching_vs_lhb is None
async def test_pd_pitcher_full_pipeline(self, mock_pd_api):
"""Test fetching and mapping PD pitcher"""
player = await PlayerFactory.create_player("pd", 11223)
# Verify game model
assert player.player_id == 11223
assert player.is_pitcher
# Verify both batting and pitching ratings
assert player.batting_vs_lhp is not None
assert player.pitching_vs_lhb is not None
assert player.pitching_vs_rhb is not None
```
---
## Test Fixtures
### Shared Fixtures (`conftest.py`)
```python
import pytest
import json
from pathlib import Path
@pytest.fixture
def sba_player_json():
"""Load SBA player JSON from file"""
path = Path(__file__).parent / "fixtures" / "sba_player.json"
with open(path) as f:
return json.load(f)
@pytest.fixture
def pd_player_json():
"""Load PD player JSON from file"""
path = Path(__file__).parent / "fixtures" / "pd_player.json"
with open(path) as f:
return json.load(f)
@pytest.fixture
def pd_batting_ratings_json():
"""Load PD batting ratings JSON from file"""
path = Path(__file__).parent / "fixtures" / "pd_batting_ratings.json"
with open(path) as f:
return json.load(f)
@pytest.fixture
def pd_pitching_ratings_json():
"""Load PD pitching ratings JSON from file"""
path = Path(__file__).parent / "fixtures" / "pd_pitching_ratings.json"
with open(path) as f:
return json.load(f)
```
### Fixture JSON Files
Store real API responses in `tests/fixtures/`:
- `sba_player.json` - From user's example
- `pd_player.json` - From user's example
- `pd_batting_ratings.json` - From user's example
- `pd_pitching_ratings.json` - From user's example
---
## Coverage Goals
| Module | Target | Priority |
|--------|--------|----------|
| `api_models.py` | 95%+ | High |
| `player_models.py` | 95%+ | High |
| `api_client.py` | 85%+ | Medium |
| Mappers | 95%+ | High |
| Integration | 80%+ | Medium |
---
## Running Tests
```bash
# All tests
pytest tests/ -v
# Only unit tests
pytest tests/unit/ -v
# Only integration tests
pytest tests/integration/ -v -m integration
# With coverage
pytest tests/ --cov=app.models --cov=app.data --cov-report=html
# Specific file
pytest tests/unit/models/test_api_models.py -v
```
---
## Test Markers
```python
# pytest.ini
[pytest]
markers =
integration: marks tests as integration tests (slow, may require network)
unit: marks tests as unit tests (fast, no external dependencies)
```
Usage:
```bash
# Run only unit tests
pytest -m unit
# Run only integration tests
pytest -m integration
# Skip integration tests
pytest -m "not integration"
```
---
**Status**: Week 6 testing strategy complete. Ready for implementation!

View File

@ -0,0 +1,540 @@
# Player Model API Examples
In all examples of API URLs, here are the baseUrl values:
- PD: https://pd.manticorum.com/
- SBa: https://api.sba.manticorum.com/
## PD Player
### API URL:
{{baseUrl}}/api/v2/players/:player_id?csv=false
### API Response
{
"player_id": 11223,
"p_name": "Matt Karchner",
"cost": 1266,
"image": "https://pd.manticorum.com/api/v2/players/11223/pitchingcard?d=2025-4-14",
"image2": null,
"mlbclub": "Chicago White Sox",
"franchise": "Chicago White Sox",
"cardset": {
"id": 20,
"name": "1998 Season",
"description": "Cards based on the 1998 MLB season",
"event": null,
"for_purchase": true,
"total_cards": 1,
"in_packs": true,
"ranked_legal": true
},
"set_num": 1006697,
"rarity": {
"id": 1,
"value": 5,
"name": "MVP",
"color": "56f1fa"
},
"pos_1": "RP",
"pos_2": "CP",
"pos_3": null,
"pos_4": null,
"pos_5": null,
"pos_6": null,
"pos_7": null,
"pos_8": null,
"headshot": "https://www.baseball-reference.com/req/202412180/images/headshots/5/506ce471_sabr.jpg",
"vanity_card": null,
"strat_code": null,
"bbref_id": "karchma01",
"fangr_id": "1006697",
"description": "1998 Season",
"quantity": 999,
"mlbplayer": {
"id": 4180,
"first_name": "Matt",
"last_name": "Karchner",
"key_fangraphs": 1006697,
"key_bbref": "karchma01",
"key_retro": "karcm001",
"key_mlbam": 116840,
"offense_col": 2
},
"paperdex": {
"count": 1,
"paperdex": [
{
"id": 40108,
"team": 69,
"player": 11223,
"created": 1742675422723
}
]
}
}
## PD Scouting Data
### Batting API URL:
{{baseUrl}}/api/v2/battingcardratings/player/:player_id?short_output=false
### Batting API Response:
{
"count": 2,
"ratings": [
{
"id": 9703,
"battingcard": {
"id": 4871,
"player": {
"player_id": 10633,
"p_name": "Chuck Knoblauch",
"cost": 77,
"image": "https://pd.manticorum.com/api/v2/players/10633/battingcard?d=2025-4-14",
"image2": null,
"mlbclub": "New York Yankees",
"franchise": "New York Yankees",
"cardset": {
"id": 20,
"name": "1998 Season",
"description": "Cards based on the 1998 MLB season",
"event": null,
"for_purchase": true,
"total_cards": 1,
"in_packs": true,
"ranked_legal": true
},
"set_num": 609,
"rarity": {
"id": 3,
"value": 2,
"name": "Starter",
"color": "C0C0C0"
},
"pos_1": "2B",
"pos_2": null,
"pos_3": null,
"pos_4": null,
"pos_5": null,
"pos_6": null,
"pos_7": null,
"pos_8": null,
"headshot": "https://www.baseball-reference.com/req/202410300/images/headshots/1/1ab36543_sabr.jpg",
"vanity_card": null,
"strat_code": null,
"bbref_id": "knoblch01",
"fangr_id": "609",
"description": "1998 Season",
"quantity": 999,
"mlbplayer": {
"id": 3699,
"first_name": "Chuck",
"last_name": "Knoblauch",
"key_fangraphs": 609,
"key_bbref": "knoblch01",
"key_retro": "knobc001",
"key_mlbam": 117197,
"offense_col": 1
}
},
"variant": 0,
"steal_low": 8,
"steal_high": 11,
"steal_auto": true,
"steal_jump": 0.25,
"bunting": "C",
"hit_and_run": "D",
"running": 13,
"offense_col": 1,
"hand": "R"
},
"vs_hand": "L",
"pull_rate": 0.29379,
"center_rate": 0.41243,
"slap_rate": 0.29378,
"homerun": 0.0,
"bp_homerun": 2.0,
"triple": 1.4,
"double_three": 0.0,
"double_two": 5.1,
"double_pull": 5.1,
"single_two": 3.5,
"single_one": 4.5,
"single_center": 1.35,
"bp_single": 5.0,
"hbp": 2.0,
"walk": 18.25,
"strikeout": 9.75,
"lineout": 9.0,
"popout": 16.0,
"flyout_a": 0.0,
"flyout_bq": 1.65,
"flyout_lf_b": 1.9,
"flyout_rf_b": 2.0,
"groundout_a": 7.0,
"groundout_b": 10.5,
"groundout_c": 2.0,
"avg": 0.2263888888888889,
"obp": 0.41388888888888886,
"slg": 0.37453703703703706
},
{
"id": 9704,
"battingcard": {
"id": 4871,
"player": {
"player_id": 10633,
"p_name": "Chuck Knoblauch",
"cost": 77,
"image": "https://pd.manticorum.com/api/v2/players/10633/battingcard?d=2025-4-14",
"image2": null,
"mlbclub": "New York Yankees",
"franchise": "New York Yankees",
"cardset": {
"id": 20,
"name": "1998 Season",
"description": "Cards based on the 1998 MLB season",
"event": null,
"for_purchase": true,
"total_cards": 1,
"in_packs": true,
"ranked_legal": true
},
"set_num": 609,
"rarity": {
"id": 3,
"value": 2,
"name": "Starter",
"color": "C0C0C0"
},
"pos_1": "2B",
"pos_2": null,
"pos_3": null,
"pos_4": null,
"pos_5": null,
"pos_6": null,
"pos_7": null,
"pos_8": null,
"headshot": "https://www.baseball-reference.com/req/202410300/images/headshots/1/1ab36543_sabr.jpg",
"vanity_card": null,
"strat_code": null,
"bbref_id": "knoblch01",
"fangr_id": "609",
"description": "1998 Season",
"quantity": 999,
"mlbplayer": {
"id": 3699,
"first_name": "Chuck",
"last_name": "Knoblauch",
"key_fangraphs": 609,
"key_bbref": "knoblch01",
"key_retro": "knobc001",
"key_mlbam": 117197,
"offense_col": 1
}
},
"variant": 0,
"steal_low": 8,
"steal_high": 11,
"steal_auto": true,
"steal_jump": 0.25,
"bunting": "C",
"hit_and_run": "D",
"running": 13,
"offense_col": 1,
"hand": "R"
},
"vs_hand": "R",
"pull_rate": 0.25824,
"center_rate": 0.1337,
"slap_rate": 0.60806,
"homerun": 1.05,
"bp_homerun": 3.0,
"triple": 1.2,
"double_three": 0.0,
"double_two": 3.5,
"double_pull": 3.5,
"single_two": 4.3,
"single_one": 6.4,
"single_center": 2.4,
"bp_single": 5.0,
"hbp": 3.0,
"walk": 12.1,
"strikeout": 9.9,
"lineout": 11.0,
"popout": 13.0,
"flyout_a": 0.0,
"flyout_bq": 1.6,
"flyout_lf_b": 1.5,
"flyout_rf_b": 1.95,
"groundout_a": 12.0,
"groundout_b": 11.6,
"groundout_c": 0.0,
"avg": 0.2439814814814815,
"obp": 0.3837962962962963,
"slg": 0.40185185185185185
}
]
}
### Pitching API URL:
{{baseUrl}}/api/v2/pitchingcardratings/player/:player_id?short_output=false
### Pitching API Response:
{
"count": 2,
"ratings": [
{
"id": 8097,
"pitchingcard": {
"id": 4049,
"player": {
"player_id": 8558,
"p_name": "Josh Lindblom",
"cost": 15,
"image": "https://pd.manticorum.com/api/v2/players/8558/pitchingcard?d=2025-3-22",
"image2": null,
"mlbclub": "Milwaukee Brewers",
"franchise": "Free Agents",
"cardset": {
"id": 1,
"name": "2021 Season",
"description": "Cards based on the full 2021 season",
"event": null,
"for_purchase": true,
"total_cards": 791,
"in_packs": true,
"ranked_legal": false
},
"set_num": 7882,
"rarity": {
"id": 5,
"value": 0,
"name": "Replacement",
"color": "454545"
},
"pos_1": "RP",
"pos_2": null,
"pos_3": null,
"pos_4": null,
"pos_5": null,
"pos_6": null,
"pos_7": null,
"pos_8": null,
"headshot": "https://www.baseball-reference.com/req/202311010/images/headshots/6/6a648ede_mlbam.jpg",
"vanity_card": null,
"strat_code": "458676",
"bbref_id": "lindbjo01",
"fangr_id": "7882",
"description": "2021",
"quantity": 999,
"mlbplayer": {
"id": 1527,
"first_name": "Josh",
"last_name": "Lindblom",
"key_fangraphs": 7882,
"key_bbref": "lindbjo01",
"key_retro": "lindj004",
"key_mlbam": 458676,
"offense_col": 2
}
},
"variant": 0,
"balk": 0,
"wild_pitch": 20,
"hold": 9,
"starter_rating": 1,
"relief_rating": 2,
"closer_rating": null,
"batting": "#1WR-C",
"offense_col": 1,
"hand": "R"
},
"vs_hand": "L",
"homerun": 2.6,
"bp_homerun": 6.0,
"triple": 2.1,
"double_three": 0.0,
"double_two": 7.1,
"double_cf": 0.0,
"single_two": 1.0,
"single_one": 1.0,
"single_center": 0.0,
"bp_single": 5.0,
"hbp": 6.0,
"walk": 17.6,
"strikeout": 11.4,
"flyout_lf_b": 0.0,
"flyout_cf_b": 7.75,
"flyout_rf_b": 3.6,
"groundout_a": 1.75,
"groundout_b": 6.1,
"xcheck_p": 1.0,
"xcheck_c": 3.0,
"xcheck_1b": 2.0,
"xcheck_2b": 6.0,
"xcheck_3b": 3.0,
"xcheck_ss": 7.0,
"xcheck_lf": 2.0,
"xcheck_cf": 3.0,
"xcheck_rf": 2.0,
"avg": 0.17870370370370367,
"obp": 0.3972222222222222,
"slg": 0.4388888888888889
},
{
"id": 8098,
"pitchingcard": {
"id": 4049,
"player": {
"player_id": 8558,
"p_name": "Josh Lindblom",
"cost": 15,
"image": "https://pd.manticorum.com/api/v2/players/8558/pitchingcard?d=2025-3-22",
"image2": null,
"mlbclub": "Milwaukee Brewers",
"franchise": "Free Agents",
"cardset": {
"id": 1,
"name": "2021 Season",
"description": "Cards based on the full 2021 season",
"event": null,
"for_purchase": true,
"total_cards": 791,
"in_packs": true,
"ranked_legal": false
},
"set_num": 7882,
"rarity": {
"id": 5,
"value": 0,
"name": "Replacement",
"color": "454545"
},
"pos_1": "RP",
"pos_2": null,
"pos_3": null,
"pos_4": null,
"pos_5": null,
"pos_6": null,
"pos_7": null,
"pos_8": null,
"headshot": "https://www.baseball-reference.com/req/202311010/images/headshots/6/6a648ede_mlbam.jpg",
"vanity_card": null,
"strat_code": "458676",
"bbref_id": "lindbjo01",
"fangr_id": "7882",
"description": "2021",
"quantity": 999,
"mlbplayer": {
"id": 1527,
"first_name": "Josh",
"last_name": "Lindblom",
"key_fangraphs": 7882,
"key_bbref": "lindbjo01",
"key_retro": "lindj004",
"key_mlbam": 458676,
"offense_col": 2
}
},
"variant": 0,
"balk": 0,
"wild_pitch": 20,
"hold": 9,
"starter_rating": 1,
"relief_rating": 2,
"closer_rating": null,
"batting": "#1WR-C",
"offense_col": 1,
"hand": "R"
},
"vs_hand": "R",
"homerun": 5.0,
"bp_homerun": 2.0,
"triple": 1.0,
"double_three": 0.0,
"double_two": 0.0,
"double_cf": 6.15,
"single_two": 6.5,
"single_one": 0.0,
"single_center": 6.5,
"bp_single": 5.0,
"hbp": 2.0,
"walk": 10.2,
"strikeout": 26.65,
"flyout_lf_b": 0.0,
"flyout_cf_b": 0.0,
"flyout_rf_b": 0.0,
"groundout_a": 6.0,
"groundout_b": 2.0,
"xcheck_p": 1.0,
"xcheck_c": 3.0,
"xcheck_1b": 2.0,
"xcheck_2b": 6.0,
"xcheck_3b": 3.0,
"xcheck_ss": 7.0,
"xcheck_lf": 2.0,
"xcheck_cf": 3.0,
"xcheck_rf": 2.0,
"avg": 0.2652777777777778,
"obp": 0.37824074074074077,
"slg": 0.5074074074074074
}
]
}
## SBa Player
### API URL:
{{baseUrl}}/players/:player_id?short_output=false
### API Response
{
"id": 12288,
"name": "Ronald Acuna Jr",
"wara": 0.0,
"image": "https://sba-cards-2024.s3.us-east-1.amazonaws.com/2024-cards/ronald-acuna-jr.png",
"image2": null,
"team": {
"id": 499,
"abbrev": "WV",
"sname": "Black Bears",
"lname": "West Virginia Black Bears",
"manager_legacy": null,
"division_legacy": null,
"gmid": "258104532423147520",
"gmid2": null,
"manager1": {
"id": 3,
"name": "Cal",
"image": null,
"headline": null,
"bio": null
},
"manager2": null,
"division": {
"id": 41,
"division_name": "Big Chungus",
"division_abbrev": "BBC",
"league_name": "SBa",
"league_abbrev": "SBa",
"season": 12
},
"mascot": null,
"stadium": "https://i.postimg.cc/rpRZ2NNF/wvpark.png",
"gsheet": null,
"thumbnail": "https://i.postimg.cc/HjDc8bBF/blackbears-transparent.png",
"color": "6699FF",
"dice_color": null,
"season": 12,
"auto_draft": null
},
"season": 12,
"pitcher_injury": null,
"pos_1": "RF",
"pos_2": null,
"pos_3": null,
"pos_4": null,
"pos_5": null,
"pos_6": null,
"pos_7": null,
"pos_8": null,
"last_game": null,
"last_game2": null,
"il_return": null,
"demotion_week": 16,
"headshot": null,
"vanity_card": null,
"strat_code": "Acuna,R",
"bbref_id": "acunaro01",
"injury_rating": "5p30",
"sbaplayer": null
}