411 lines
15 KiB
Python
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())
|