diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..ad32aae --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,99 @@ +# Paper Dynasty Deployment Guide + +## Current Deployment Process (Shell Script) + +### Quick Start + +From the `discord-app` directory: + +```bash +./deploy.sh 1.3.0 +``` + +Or let it prompt you for version: + +```bash +./deploy.sh +``` + +### What It Does + +1. **Validates version format** (X.Y or X.Y.Z) +2. **Builds Docker image** with both `latest` and version tags +3. **Pushes to Docker Hub** (manticorum67/paper-dynasty) +4. **Creates git tag** (v1.3.0) +5. **Deploys to sba-bots** via SSH +6. **Cleans up old images** + +### Version Numbering + +- **Bug fixes**: Increment patch (1.2.0 → 1.2.1) +- **New features**: Increment minor (1.2.1 → 1.3.0) +- **Breaking changes**: Increment major (1.3.0 → 2.0.0) + +### Examples + +```bash +# Deploy a bug fix +./deploy.sh 1.2.1 + +# Deploy a new feature +./deploy.sh 1.3.0 + +# Deploy with 'v' prefix (works either way) +./deploy.sh v1.3.0 +``` + +### Rollback Process + +If something goes wrong: + +```bash +ssh sba-bots +cd /container-data/paper-dynasty + +# Check available versions +docker image ls | grep paper-dynasty + +# Update docker-compose.yml to use specific version +# Change: image: manticorum67/paper-dynasty:latest +# To: image: manticorum67/paper-dynasty:1.2.0 + +docker compose up -d +``` + +## Future: Automated CI/CD (GitHub Actions + Watchtower) + +When ready to automate further, we'll: + +1. Add GitHub Actions workflow to auto-build on git tags +2. Install Watchtower on sba-bots to auto-deploy +3. Push tags and walk away ☕ + +Files ready in `.github/workflows/` when you're ready to switch. + +## Troubleshooting + +### "Permission denied" when running script +```bash +chmod +x deploy.sh +``` + +### "Docker build failed" +- Check Dockerfile syntax +- Ensure all required files are present +- Check Docker daemon is running + +### "Failed to push to Docker Hub" +- Verify you're logged in: `docker login` +- Check Docker Hub credentials + +### "SSH connection failed" +- Verify SSH key is configured: `ssh sba-bots` +- Check ~/.ssh/config has sba-bots entry + +### Remote deployment fails but build succeeds +- SSH into sba-bots manually +- Check disk space: `df -h` +- Check docker-compose.yml is present in `/container-data/paper-dynasty/` +- Run docker compose commands manually to see error diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..faf06ce --- /dev/null +++ b/deploy.sh @@ -0,0 +1,181 @@ +#!/bin/bash +set -e # Exit on error + +# Configuration +DOCKER_IMAGE="manticorum67/paper-dynasty" +REMOTE_HOST="sba-bots" +REMOTE_PATH="/home/cal/container-data/paper-dynasty" +DOCKERFILE_PATH="." +AUTO_YES=false + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Helper functions +log_info() { + echo -e "${BLUE}ℹ️ $1${NC}" +} + +log_success() { + echo -e "${GREEN}✅ $1${NC}" +} + +log_warning() { + echo -e "${YELLOW}⚠️ $1${NC}" +} + +log_error() { + echo -e "${RED}❌ $1${NC}" +} + +# Parse arguments +while [[ $# -gt 0 ]]; do + case $1 in + -y|--yes) + AUTO_YES=true + shift + ;; + *) + VERSION=$1 + shift + ;; + esac +done + +# Get version from argument or prompt +if [ -z "$VERSION" ]; then + echo "" + log_info "Recent version tags:" + git tag -l "v*" | sort -V | tail -5 + echo "" + read -p "Enter new version (e.g., 1.3.0 for features, 1.2.1 for bugfix): " VERSION +fi + +# Remove 'v' prefix if provided +VERSION=${VERSION#v} + +# Validate version format +if ! [[ $VERSION =~ ^[0-9]+\.[0-9]+(\.[0-9]+)?$ ]]; then + log_error "Version must be in format X.Y or X.Y.Z (e.g., 1.3 or 1.3.0)" + exit 1 +fi + +# Confirm deployment +echo "" +log_warning "About to deploy Paper Dynasty version ${VERSION}" +log_info "This will:" +echo " 1. Build Docker image with tags: latest and ${VERSION}" +echo " 2. Push both tags to Docker Hub (${DOCKER_IMAGE})" +echo " 3. Create git tag v${VERSION}" +echo " 4. Deploy to ${REMOTE_HOST}" +echo "" +if [ "$AUTO_YES" = false ]; then + read -p "Continue? (y/N): " -n 1 -r + echo "" + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + log_info "Deployment cancelled" + exit 0 + fi +else + log_info "Auto-confirmed with --yes flag" +fi + +# Check if git tag already exists +if git rev-parse "v${VERSION}" >/dev/null 2>&1; then + log_error "Git tag v${VERSION} already exists!" + log_info "If you want to redeploy, delete the tag first: git tag -d v${VERSION} && git push origin :refs/tags/v${VERSION}" + exit 1 +fi + +# Check for uncommitted changes +if ! git diff-index --quiet HEAD --; then + log_warning "You have uncommitted changes!" + git status --short + echo "" + if [ "$AUTO_YES" = false ]; then + read -p "Continue anyway? (y/N): " -n 1 -r + echo "" + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + log_info "Deployment cancelled" + exit 0 + fi + else + log_warning "Continuing with uncommitted changes (--yes flag)" + fi +fi + +echo "" +log_info "🏗️ Building Docker image for version ${VERSION}..." +if docker build -t $DOCKER_IMAGE:latest -t $DOCKER_IMAGE:$VERSION $DOCKERFILE_PATH; then + log_success "Docker image built successfully" +else + log_error "Docker build failed" + exit 1 +fi + +echo "" +log_info "📦 Pushing images to Docker Hub..." +log_info "Pushing ${DOCKER_IMAGE}:latest..." +if docker push $DOCKER_IMAGE:latest; then + log_success "Pushed latest tag" +else + log_error "Failed to push latest tag" + exit 1 +fi + +log_info "Pushing ${DOCKER_IMAGE}:${VERSION}..." +if docker push $DOCKER_IMAGE:$VERSION; then + log_success "Pushed version ${VERSION} tag" +else + log_error "Failed to push version tag" + exit 1 +fi + +echo "" +log_info "🏷️ Creating git tag v${VERSION}..." +git tag -a "v${VERSION}" -m "Release v${VERSION}" +git push origin "v${VERSION}" +log_success "Git tag created and pushed" + +echo "" +log_info "🚀 Deploying to ${REMOTE_HOST}..." +log_info "Running: cd ${REMOTE_PATH} && docker-compose pull" + +if ssh $REMOTE_HOST "cd $REMOTE_PATH && docker-compose pull"; then + log_success "Successfully pulled latest image" +else + log_error "Failed to pull image on remote server" + exit 1 +fi + +log_info "Running: docker-compose down && docker-compose up -d" +if ssh $REMOTE_HOST "cd $REMOTE_PATH && docker-compose down && docker-compose up -d"; then + log_success "Container restarted successfully" +else + log_error "Failed to restart container" + log_warning "You may need to SSH in manually to fix: ssh ${REMOTE_HOST}" + exit 1 +fi + +# Optional: Clean up old images on remote +log_info "Cleaning up old Docker images on remote..." +ssh $REMOTE_HOST "docker image prune -f" >/dev/null 2>&1 || true + +echo "" +log_success "🎉 Deployment complete!" +echo "" +log_info "Version v${VERSION} is now running on ${REMOTE_HOST}" +log_info "Check logs with: ssh ${REMOTE_HOST} 'cd ${REMOTE_PATH} && docker-compose logs -f paper-dynasty'" +log_info "Check status with: ssh ${REMOTE_HOST} 'cd ${REMOTE_PATH} && docker-compose ps'" +echo "" +log_info "If you need to rollback:" +echo " ssh ${REMOTE_HOST}" +echo " cd ${REMOTE_PATH}" +echo " # Find previous version: docker image ls | grep paper-dynasty" +echo " # Edit docker-compose.yml to use specific version tag" +echo " docker compose up -d" +echo "" diff --git a/discord_ui/selectors.py b/discord_ui/selectors.py index 5dcfbe3..2f344fe 100644 --- a/discord_ui/selectors.py +++ b/discord_ui/selectors.py @@ -210,13 +210,25 @@ class SelectOpenPack(discord.ui.Select): p_query = await db_get('packs', params=params) if p_query['count'] == 0: logger.error(f'open-packs - no packs found with params: {params}') - raise ValueError(f'Unable to open packs') + await interaction.followup.send( + content='Unable to find the selected pack. Please contact an admin.', + ephemeral=True + ) + return # Open the packs - if open_type == 'standard': - await open_st_pr_packs(p_query['packs'], self.owner_team, interaction) - elif open_type == 'choice': - await open_choice_pack(p_query['packs'][0], self.owner_team, interaction, cardset_id) + try: + if open_type == 'standard': + await open_st_pr_packs(p_query['packs'], self.owner_team, interaction) + elif open_type == 'choice': + await open_choice_pack(p_query['packs'][0], self.owner_team, interaction, cardset_id) + except Exception as e: + logger.error(f'Failed to open pack: {e}') + await interaction.followup.send( + content=f'Failed to open pack. Please contact an admin. Error: {str(e)}', + ephemeral=True + ) + return class SelectPaperdexCardset(discord.ui.Select): diff --git a/helpers/main.py b/helpers/main.py index 06519b2..ef715c3 100644 --- a/helpers/main.py +++ b/helpers/main.py @@ -1711,7 +1711,8 @@ async def open_choice_pack(this_pack, team: dict, context, cardset_id: Optional[ ('min_rarity', min_rarity), ('max_rarity', rarity_id), ('limit', 4 - len(players)), ('franchise', this_pack['pack_team']['lname']) ] - if this_pack['pack_team']['abbrev'] not in ['MSS']: + # Only apply in_packs filter if no specific cardset is provided + if this_pack['pack_team']['abbrev'] not in ['MSS'] and cardset_id is None: params.append(('in_packs', True)) if cardset_id is not None: params.append(('cardset_id', cardset_id)) @@ -1762,8 +1763,11 @@ async def open_choice_pack(this_pack, team: dict, context, cardset_id: Optional[ min_rarity = rarity_id while len(players) < 4 and rarity_id < 10: params = [ - ('min_rarity', min_rarity), ('max_rarity', rarity_id), ('limit', 4), ('in_packs', True) + ('min_rarity', min_rarity), ('max_rarity', rarity_id), ('limit', 4) ] + # Only apply in_packs filter if no specific cardset is provided + if cardset_id is None: + params.append(('in_packs', True)) if this_pack['pack_team'] is not None: params.append(('franchise', this_pack['pack_team']['lname'])) if cardset_id is not None: diff --git a/tests/command_logic/test_logic_gameplay.py b/tests/command_logic/test_logic_gameplay.py index 9a06fb7..9824138 100644 --- a/tests/command_logic/test_logic_gameplay.py +++ b/tests/command_logic/test_logic_gameplay.py @@ -1,8 +1,8 @@ import pytest from sqlmodel import Session, select, func -from command_logic.logic_gameplay import advance_runners, doubles, gb_result_1, get_obc, get_re24, get_wpa, complete_play, log_run_scored, strikeouts, steals, xchecks, walks, popouts, hit_by_pitch, homeruns, singles, triples, bunts, chaos, safe_wpa_lookup -from in_game.gameplay_models import Lineup, Play +from command_logic.logic_gameplay import advance_runners, doubles, gb_result_1, get_obc, get_re24, get_wpa, complete_play, log_run_scored, strikeouts, steals, xchecks, walks, popouts, hit_by_pitch, homeruns, singles, triples, bunts, chaos, safe_wpa_lookup, create_pinch_runner_entry_play +from in_game.gameplay_models import Lineup, Play, Card, Player from tests.factory import session_fixture, Game @@ -464,8 +464,108 @@ async def test_chaos(session: Session): # Test pickoff play_4 = complete_play(session, play_3) play_4 = await chaos(session, None, play_4, 'pickoff') - + assert play_4.pa == 0 assert play_4.ab == 0 assert play_4.pick_off == 1 # Pickoff recorded assert play_4.outs == 1 # Out recorded + + +def test_pinch_runner_entry_and_scoring(session: Session): + """ + Test that pinch runners get an entry Play record when they substitute for a runner on base, + and that the run is properly credited when they score. + """ + this_game = session.get(Game, 3) + + # Step 1: Batter A gets a single + play_1 = this_game.initialize_play(session) + play_1.pa, play_1.ab, play_1.hit, play_1.batter_final = 1, 1, 1, 1 + + # Complete the play - batter A is now on first + play_2 = complete_play(session, play_1) + assert play_2.on_first == play_1.batter # Original batter on first + assert play_2.on_first.player_id == play_1.batter.player_id + + # Step 2: Create a pinch runner lineup (simulating /substitute batter) + # Get a different player/card for the pinch runner + pinch_runner_card = session.get(Card, 2) # Different card than the one on base + pinch_runner_lineup = Lineup( + team=play_2.on_first.team, + player=pinch_runner_card.player, + card=pinch_runner_card, + position='PR', + batting_order=play_2.on_first.batting_order, + game=this_game, + after_play=play_2.play_num - 1, + replacing_id=play_2.on_first.id + ) + session.add(pinch_runner_lineup) + session.commit() + session.refresh(pinch_runner_lineup) + + # Step 3: Update the current play to reference the pinch runner + play_2.on_first = pinch_runner_lineup + session.add(play_2) + session.commit() + + # Step 4: Create the pinch runner entry Play + entry_play = create_pinch_runner_entry_play( + session=session, + game=this_game, + current_play=play_2, + pinch_runner_lineup=pinch_runner_lineup + ) + + # Verify the entry Play was created correctly + assert entry_play is not None + assert entry_play.batter == pinch_runner_lineup + assert entry_play.pa == 0 # NOT a plate appearance + assert entry_play.ab == 0 # NOT an at-bat + assert entry_play.run == 0 # Not scored yet + assert entry_play.complete == True # Entry is complete + assert entry_play.game == this_game + assert entry_play.pitcher == play_2.pitcher + assert entry_play.inning_half == play_2.inning_half + assert entry_play.inning_num == play_2.inning_num + + # Step 5: Advance the pinch runner to third + play_2.hit = 1 + play_2.batter_final = 1 + play_2 = advance_runners(session, play_2, num_bases=2) # Advance runner 2 bases + session.add(play_2) + session.commit() + + assert play_2.on_first_final == 3 # Pinch runner advanced to third + + # Complete the play - pinch runner is now on third + play_3 = complete_play(session, play_2) + assert play_3.on_third == pinch_runner_lineup + + # Step 6: Score the pinch runner on a subsequent hit + play_3.pa, play_3.ab, play_3.hit, play_3.batter_final = 1, 1, 1, 1 + play_3 = advance_runners(session, play_3, num_bases=1) # Score from third + session.add(play_3) + session.commit() + + assert play_3.on_third_final == 4 # Runner scored + assert play_3.rbi >= 1 # RBI for the batter + + # Step 7: Verify the run was credited to the pinch runner's entry Play + session.refresh(entry_play) + assert entry_play.run == 1 # Pinch runner's entry Play has run=1 + assert entry_play.pa == 0 # Still not a plate appearance + assert entry_play.ab == 0 # Still not an at-bat + + # Step 8: Verify pitcher stats are not affected by the entry Play + # The pitcher allowed a hit (play_1) but the entry Play (PA=0, AB=0) should not count + pitcher_plays = session.exec( + select(Play).where( + Play.game == this_game, + Play.pitcher == play_2.pitcher, + Play.pa > 0 + ) + ).all() + + # Entry play should NOT be in this list (PA=0) + assert entry_play not in pitcher_plays diff --git a/tests/factory.py b/tests/factory.py index e802fb8..03ce3f0 100644 --- a/tests/factory.py +++ b/tests/factory.py @@ -483,6 +483,11 @@ def session_fixture(): session.add(manager_ai_3) session.commit() + # Reset sequences so auto-generated IDs don't conflict with factory data + session.exec(text("SELECT setval(pg_get_serial_sequence('lineup', 'id'), COALESCE((SELECT MAX(id) FROM lineup), 1))")) + session.exec(text("SELECT setval(pg_get_serial_sequence('play', 'id'), COALESCE((SELECT MAX(id) FROM play), 1))")) + session.commit() + yield session