Files
echoes-of-the-ash/old/migrate_account_player_separation.py
2025-11-27 16:27:01 +01:00

411 lines
15 KiB
Python

#!/usr/bin/env python3
"""
Major Database Migration: Account/Player Separation
====================================================
This migration separates authentication (accounts) from gameplay (characters):
- Creates new 'accounts' table for login credentials
- Creates new 'characters' table for game data
- Migrates existing 'players' data to both tables
- Updates foreign keys in related tables
- Drops old 'players' table
IMPORTANT: This is a breaking change. Backup your database first!
"""
import asyncio
import asyncpg
import os
from datetime import datetime
# Database connection
DB_USER = os.getenv("POSTGRES_USER")
DB_PASS = os.getenv("POSTGRES_PASSWORD")
DB_NAME = os.getenv("POSTGRES_DB")
DB_HOST = os.getenv("POSTGRES_HOST", "echoes_of_the_ashes_db")
DB_PORT = os.getenv("POSTGRES_PORT", "5432")
DATABASE_URL = f"postgresql://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
async def main():
print("=" * 70)
print("ACCOUNT/PLAYER SEPARATION MIGRATION")
print("=" * 70)
print()
conn = await asyncpg.connect(DATABASE_URL)
try:
# Step 0: Check if migration already ran
print("Step 0: Checking migration status...")
tables_exist = await conn.fetchval("""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'accounts'
)
""")
if tables_exist:
print("⚠️ Accounts table already exists. Migration may have already run.")
print(" Cleaning up previous migration attempt...")
await conn.execute("DROP TABLE IF EXISTS characters CASCADE;")
await conn.execute("DROP TABLE IF EXISTS accounts CASCADE;")
await conn.execute("DROP TABLE IF EXISTS players_backup_20251109 CASCADE;")
print("✅ Cleaned up existing tables")
print()
# Step 1: Backup existing players table
print("Step 1: Creating backup of players table...")
await conn.execute("""
CREATE TABLE IF NOT EXISTS players_backup_20251109 AS
SELECT * FROM players;
""")
backup_count = await conn.fetchval(
"SELECT COUNT(*) FROM players_backup_20251109"
)
print(f"✅ Backed up {backup_count} players to players_backup_20251109")
print()
# Step 2: Create temporary mapping table
print("Step 2: Creating temporary mapping table...")
await conn.execute("""
CREATE TEMP TABLE IF NOT EXISTS player_character_mapping (
old_player_id INTEGER,
new_character_id INTEGER
);
""")
print("✅ Created temporary mapping table")
print()
# Step 3: Create accounts table
print("Step 3: Creating accounts table...")
await conn.execute("""
CREATE TABLE IF NOT EXISTS accounts (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash VARCHAR(255),
steam_id VARCHAR(255) UNIQUE,
account_type VARCHAR(20) DEFAULT 'web',
premium_expires_at REAL,
email_verified BOOLEAN DEFAULT FALSE,
email_verification_token VARCHAR(255),
password_reset_token VARCHAR(255),
password_reset_expires REAL,
created_at REAL DEFAULT EXTRACT(EPOCH FROM NOW()),
last_login_at REAL,
CONSTRAINT check_account_type CHECK (account_type IN ('web', 'steam'))
);
""")
print("✅ Created accounts table")
# Create indexes for accounts
await conn.execute("""
CREATE INDEX IF NOT EXISTS idx_accounts_email ON accounts(email);
""")
await conn.execute("""
CREATE INDEX IF NOT EXISTS idx_accounts_steam_id ON accounts(steam_id);
""")
print("✅ Created indexes on accounts table")
print()
# Step 4: Create characters table
print("Step 4: Creating characters table...")
await conn.execute("""
CREATE TABLE IF NOT EXISTS characters (
id SERIAL PRIMARY KEY,
account_id INTEGER NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
name VARCHAR(100) UNIQUE NOT NULL,
avatar_data TEXT,
-- RPG Stats
level INTEGER DEFAULT 1,
xp INTEGER DEFAULT 0,
hp INTEGER DEFAULT 100,
max_hp INTEGER DEFAULT 100,
stamina INTEGER DEFAULT 100,
max_stamina INTEGER DEFAULT 100,
-- Base Attributes
strength INTEGER DEFAULT 0,
agility INTEGER DEFAULT 0,
endurance INTEGER DEFAULT 0,
intellect INTEGER DEFAULT 0,
unspent_points INTEGER DEFAULT 0,
-- Game State
location_id VARCHAR(255) DEFAULT 'cabin',
is_dead BOOLEAN DEFAULT FALSE,
last_movement_time REAL DEFAULT 0,
-- Timestamps
created_at REAL DEFAULT EXTRACT(EPOCH FROM NOW()),
last_played_at REAL DEFAULT EXTRACT(EPOCH FROM NOW()),
CONSTRAINT check_unspent_points CHECK (unspent_points >= 0)
);
""")
print("✅ Created characters table")
# Create indexes for characters
await conn.execute("""
CREATE INDEX IF NOT EXISTS idx_characters_account_id ON characters(account_id);
""")
await conn.execute("""
CREATE INDEX IF NOT EXISTS idx_characters_name ON characters(name);
""")
await conn.execute("""
CREATE INDEX IF NOT EXISTS idx_characters_location_id ON characters(location_id);
""")
print("✅ Created indexes on characters table")
print()
# Step 5: Migrate existing players to accounts and characters
print("Step 5: Migrating existing players...")
# Get all existing players
players = await conn.fetch("SELECT * FROM players ORDER BY id")
print(f"Found {len(players)} players to migrate")
migrated = 0
character_names_used = set()
for player in players:
# Generate email if not present
email = player['email']
if not email:
username = player['username'] or f"player_{player['id']}"
email = f"{username}@echoes-migrated.local"
# Ensure unique character name
char_name = player['name']
if char_name in character_names_used or char_name == "Survivor":
# Make it unique
char_name = f"{player['username'] or 'Survivor'}_{player['id']}"
character_names_used.add(char_name)
# Convert to timestamp (float)
now_timestamp = datetime.utcnow().timestamp()
# Create account
account_id = await conn.fetchval("""
INSERT INTO accounts (
email, password_hash, steam_id, account_type,
premium_expires_at, created_at, last_login_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id
""",
email,
player['password_hash'],
player.get('steam_id'),
player.get('account_type', 'web'),
player.get('premium_expires_at'), # Keep as is (NULL or timestamp)
now_timestamp,
now_timestamp
)
# Create character from player data
character_id = await conn.fetchval("""
INSERT INTO characters (
account_id, name, avatar_data,
level, xp, hp, max_hp, stamina, max_stamina,
strength, agility, endurance, intellect, unspent_points,
location_id, is_dead, last_movement_time,
created_at, last_played_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19)
RETURNING id
""",
account_id,
char_name, # Use unique character name
None, # avatar_data
player['level'],
player['xp'],
player['hp'],
player['max_hp'],
player['stamina'],
player['max_stamina'],
player['strength'],
player['agility'],
player['endurance'],
player['intellect'],
player['unspent_points'],
player['location_id'],
player['is_dead'],
player['last_movement_time'],
now_timestamp,
now_timestamp
)
# Store mapping for foreign key updates
await conn.execute("""
INSERT INTO player_character_mapping (old_player_id, new_character_id)
VALUES ($1, $2)
""", player['id'], character_id)
migrated += 1
if migrated % 10 == 0:
print(f" Migrated {migrated}/{len(players)} players...")
print("✅ Migrated {migrated} players to accounts and characters")
print()
# Step 6: Update foreign keys in related tables
print("Step 6: Updating foreign keys in related tables...")
# Update inventory table
if await conn.fetchval("""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'inventory'
)
"""):
print(" Updating inventory.player_id -> character_id...")
# Add new character_id column
await conn.execute("""
ALTER TABLE inventory
ADD COLUMN IF NOT EXISTS character_id INTEGER;
""")
# Copy player_id to character_id using mapping
await conn.execute("""
UPDATE inventory i
SET character_id = m.new_character_id
FROM player_character_mapping m
WHERE i.player_id = m.old_player_id;
""")
# Drop old player_id column and rename
await conn.execute("""
ALTER TABLE inventory DROP COLUMN IF EXISTS player_id;
""")
# Add foreign key constraint
await conn.execute("""
ALTER TABLE inventory
ADD CONSTRAINT fk_inventory_character
FOREIGN KEY (character_id) REFERENCES characters(id) ON DELETE CASCADE;
""")
print(" ✅ Updated inventory table")
# Update equipment table
if await conn.fetchval("""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'equipment'
)
"""):
print(" Updating equipment.player_id -> character_id...")
await conn.execute("""
ALTER TABLE equipment
ADD COLUMN IF NOT EXISTS character_id INTEGER;
""")
await conn.execute("""
UPDATE equipment e
SET character_id = m.new_character_id
FROM player_character_mapping m
WHERE e.player_id = m.old_player_id;
""")
await conn.execute("""
ALTER TABLE equipment DROP COLUMN IF EXISTS player_id;
""")
await conn.execute("""
ALTER TABLE equipment
ADD CONSTRAINT fk_equipment_character
FOREIGN KEY (character_id) REFERENCES characters(id) ON DELETE CASCADE;
""")
print(" ✅ Updated equipment table")
# Update dropped_items table
if await conn.fetchval("""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'dropped_items'
)
"""):
# Check if column exists
has_player_col = await conn.fetchval("""
SELECT EXISTS (
SELECT FROM information_schema.columns
WHERE table_name = 'dropped_items'
AND column_name = 'dropped_by_player_id'
)
""")
if has_player_col:
print(" Updating dropped_items.dropped_by_player_id -> dropped_by_character_id...")
await conn.execute("""
ALTER TABLE dropped_items
ADD COLUMN IF NOT EXISTS dropped_by_character_id INTEGER;
""")
await conn.execute("""
UPDATE dropped_items d
SET dropped_by_character_id = m.new_character_id
FROM player_character_mapping m
WHERE d.dropped_by_player_id = m.old_player_id;
""")
await conn.execute("""
ALTER TABLE dropped_items DROP COLUMN IF EXISTS dropped_by_player_id;
""")
print(" ✅ Updated dropped_items table")
else:
print(" ⏭️ Skipping dropped_items (no dropped_by_player_id column)")
print("✅ Updated all foreign key references")
print()
# Step 7: Drop old players table
print("Step 7: Dropping old players table...")
print("⚠️ WARNING: About to drop players table (backup exists as players_backup_20251109)")
print(" Press Ctrl+C within 5 seconds to cancel...")
await asyncio.sleep(5)
await conn.execute("DROP TABLE IF EXISTS players;")
print("✅ Dropped players table")
print()
# Step 8: Summary
print("=" * 70)
print("MIGRATION COMPLETED SUCCESSFULLY!")
print("=" * 70)
account_count = await conn.fetchval("SELECT COUNT(*) FROM accounts")
character_count = await conn.fetchval("SELECT COUNT(*) FROM characters")
print(f"✅ Created {account_count} accounts")
print(f"✅ Created {character_count} characters")
print(f"✅ Backup preserved in: players_backup_20251109")
print()
print("Next steps:")
print("1. Update application code to use new schema")
print("2. Rebuild and restart API container")
print("3. Test authentication and character selection")
print("4. Update frontend to show character selection screen")
print()
except Exception as e:
print(f"\n❌ ERROR: {e}")
print("\nRolling back changes...")
await conn.execute("ROLLBACK;")
print("Migration failed. Database unchanged.")
raise
finally:
await conn.close()
if __name__ == "__main__":
asyncio.run(main())