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
|
**Duration**: Week 6 of Phase 2
|
||||||
**Prerequisites**: Week 5 Complete (Game engine working)
|
**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
|
## 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
|
## Goals
|
||||||
|
|
||||||
By end of Week 6:
|
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)
|
- ✅ League configuration framework (BaseConfig → SbaConfig/PdConfig)
|
||||||
- ✅ Result chart system for both leagues
|
- ✅ 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
|
- ✅ Complete end-to-end at-bat for both SBA and PD
|
||||||
- ✅ Full test coverage
|
- ✅ Full test coverage
|
||||||
|
|
||||||
@ -33,13 +54,22 @@ By end of Week 6:
|
|||||||
│ ↓ ↓ │
|
│ ↓ ↓ │
|
||||||
│ SbaConfig PdConfig │
|
│ SbaConfig PdConfig │
|
||||||
│ ↓ ↓ │
|
│ ↓ ↓ │
|
||||||
│ SbaPlayer PdPlayer │
|
│ SbaPlayer PdPlayer (see separate docs) │
|
||||||
└──────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
## Components to Build
|
## 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.
|
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