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:
parent
f3238c4e6d
commit
f9aa653c37
227
.claude/PHASE_1_CATCHUP_PLAN.md
Normal file
227
.claude/PHASE_1_CATCHUP_PLAN.md
Normal 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
|
||||
224
.claude/PHASE_1_STATUS_SUMMARY.md
Normal file
224
.claude/PHASE_1_STATUS_SUMMARY.md
Normal 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.
|
||||
@ -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.
|
||||
|
||||
|
||||
312
.claude/implementation/02-week6-player-models-overview.md
Normal file
312
.claude/implementation/02-week6-player-models-overview.md
Normal 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
|
||||
545
.claude/implementation/player-model-specs/api-client.md
Normal file
545
.claude/implementation/player-model-specs/api-client.md
Normal 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
|
||||
633
.claude/implementation/player-model-specs/api-models-pd.md
Normal file
633
.claude/implementation/player-model-specs/api-models-pd.md
Normal 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
|
||||
312
.claude/implementation/player-model-specs/api-models-sba.md
Normal file
312
.claude/implementation/player-model-specs/api-models-sba.md
Normal 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
|
||||
493
.claude/implementation/player-model-specs/game-models.md
Normal file
493
.claude/implementation/player-model-specs/game-models.md
Normal 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
|
||||
@ -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
|
||||
605
.claude/implementation/player-model-specs/testing-strategy.md
Normal file
605
.claude/implementation/player-model-specs/testing-strategy.md
Normal 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!
|
||||
540
backend/app/models/player_model_info.md
Normal file
540
backend/app/models/player_model_info.md
Normal 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
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user