691 lines
No EOL
31 KiB
Python
Executable file
691 lines
No EOL
31 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""
|
|
Game Guide PDF Generator
|
|
|
|
Reads all_hostable_games_union.csv and generates comprehensive self-hosting guides
|
|
for each game in both Markdown and PDF formats.
|
|
|
|
Features:
|
|
- No provider mentions (hosting-agnostic)
|
|
- Exhaustive startup parameters and troubleshooting
|
|
- Complete port mappings and configuration details
|
|
- Steam Workshop integration where applicable
|
|
- Professional PDF output with TOC and styling
|
|
|
|
Output Structure:
|
|
- out/md/<sanitized-game>.md - Markdown source
|
|
- out/pdfs/<sanitized-game>.pdf - PDF output
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import csv
|
|
import re
|
|
import subprocess
|
|
import json
|
|
from pathlib import Path
|
|
from datetime import datetime
|
|
import pandas as pd
|
|
|
|
class GameGuideGenerator:
|
|
def __init__(self, csv_path="all_hostable_games_union.csv",
|
|
output_md="out/md", output_pdf="out/pdfs", template_path="templates/game_guide.md"):
|
|
self.csv_path = Path(csv_path)
|
|
self.output_md = Path(output_md)
|
|
self.output_pdf = Path(output_pdf)
|
|
self.template_path = Path(template_path)
|
|
|
|
# Ensure output directories exist
|
|
self.output_md.mkdir(parents=True, exist_ok=True)
|
|
self.output_pdf.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Forbidden provider mentions (must be filtered out)
|
|
self.forbidden_terms = [
|
|
'OGP', 'LinuxGSM', 'Nitrado', 'GameServers.com', 'G-Portal',
|
|
'OpenGamePanel'
|
|
]
|
|
|
|
# Common game engines for enhanced data
|
|
self.engine_mapping = {
|
|
'Counter-Strike': 'Source Engine', 'Counter-Strike 2': 'Source 2',
|
|
'Counter-Strike: Source': 'Source Engine', 'Counter-Strike: Global Offensive': 'Source Engine',
|
|
'Team Fortress 2': 'Source Engine', 'Left 4 Dead 2': 'Source Engine',
|
|
'Garry\'s Mod': 'Source Engine', 'Half-Life 2: Deathmatch': 'Source Engine',
|
|
'ARK: Survival Evolved': 'Unreal Engine 4', 'ARK: Survival Ascended': 'Unreal Engine 5',
|
|
'Squad': 'Unreal Engine 4', 'Insurgency: Sandstorm': 'Unreal Engine 4',
|
|
'Mordhau': 'Unreal Engine 4', 'Killing Floor 2': 'Unreal Engine 3',
|
|
'Rust': 'Unity', 'Valheim': 'Unity', '7 Days to Die': 'Unity',
|
|
'Unturned': 'Unity', 'Escape from Tarkov': 'Unity',
|
|
'ARMA 3': 'Real Virtuality 4', 'ARMA 2': 'Real Virtuality 3',
|
|
'DayZ': 'Enfusion', 'DayZ Standalone': 'Enfusion',
|
|
'Minecraft': 'Java', 'Minecraft: Java Edition': 'Java',
|
|
'Terraria': 'XNA/MonoGame', 'Starbound': 'Custom C++',
|
|
'Factorio': 'Custom C++', 'Space Engineers': 'VRAGE 2.0',
|
|
'Project Zomboid': 'Java', 'Don\'t Starve Together': 'Custom',
|
|
'Palworld': 'Unreal Engine 5', 'Satisfactory': 'Unreal Engine 4',
|
|
'Conan Exiles': 'Unreal Engine 4', 'The Forest': 'Unity',
|
|
'Sons of the Forest': 'Unity', 'Green Hell': 'Unity',
|
|
'V Rising': 'Unity', 'Core Keeper': 'Unity',
|
|
'Enshrouded': 'Custom', 'Palworld': 'Unreal Engine 5'
|
|
}
|
|
|
|
# Common Steam App IDs
|
|
self.appid_mapping = {
|
|
'Counter-Strike 2': '730', 'Counter-Strike: Global Offensive': '730',
|
|
'Team Fortress 2': '440', 'Left 4 Dead 2': '550',
|
|
'Garry\'s Mod': '4000', 'Rust': '258550',
|
|
'ARK: Survival Evolved': '376030', 'Squad': '393380',
|
|
'Valheim': '892970', '7 Days to Die': '294420',
|
|
'Unturned': '304930', 'ARMA 3': '107410',
|
|
'DayZ': '221100', 'Terraria': '105600',
|
|
'Project Zomboid': '108600', 'Don\'t Starve Together': '322330',
|
|
'Space Engineers': '244850', 'Conan Exiles': '440900',
|
|
'The Forest': '242760', 'Killing Floor 2': '232090',
|
|
'Insurgency: Sandstorm': '581320', 'Mordhau': '629760'
|
|
}
|
|
|
|
def load_template(self):
|
|
"""Load the Markdown template"""
|
|
try:
|
|
with open(self.template_path, 'r', encoding='utf-8') as f:
|
|
return f.read()
|
|
except FileNotFoundError:
|
|
print(f"Error: Template file not found: {self.template_path}")
|
|
sys.exit(1)
|
|
|
|
def read_csv_games(self):
|
|
"""Read games from CSV file"""
|
|
if not self.csv_path.exists():
|
|
print(f"Error: CSV file not found: {self.csv_path}")
|
|
sys.exit(1)
|
|
|
|
games = []
|
|
try:
|
|
df = pd.read_csv(self.csv_path)
|
|
|
|
# Find the game name column (could be 'Game', 'Title', etc.)
|
|
game_column = None
|
|
for col in df.columns:
|
|
if col.lower() in ['game', 'title', 'name']:
|
|
game_column = col
|
|
break
|
|
|
|
if not game_column:
|
|
# Use first column as fallback
|
|
game_column = df.columns[0]
|
|
print(f"Warning: Using first column '{game_column}' as game name column")
|
|
|
|
# Extract unique games (remove duplicates and empty entries)
|
|
for _, row in df.iterrows():
|
|
game_name = row[game_column]
|
|
if pd.notna(game_name) and game_name.strip():
|
|
# Remove numbering prefix if present (e.g., "1.Game" -> "Game")
|
|
game_name = re.sub(r'^\d+\.', '', str(game_name)).strip()
|
|
if game_name and game_name not in [g['name'] for g in games]:
|
|
games.append({'name': game_name})
|
|
|
|
print(f"Loaded {len(games)} unique games from {self.csv_path}")
|
|
return sorted(games, key=lambda x: x['name'])
|
|
|
|
except Exception as e:
|
|
print(f"Error reading CSV file: {e}")
|
|
sys.exit(1)
|
|
|
|
def sanitize_filename(self, name):
|
|
"""Create a safe filename from game name"""
|
|
# Remove special characters, replace spaces with hyphens
|
|
sanitized = re.sub(r'[^\w\s-]', '', name)
|
|
sanitized = re.sub(r'[-\s]+', '-', sanitized).strip('-').lower()
|
|
return sanitized
|
|
|
|
def detect_engine(self, game_name):
|
|
"""Detect game engine based on name patterns"""
|
|
# Direct mapping first
|
|
if game_name in self.engine_mapping:
|
|
return self.engine_mapping[game_name]
|
|
|
|
# Pattern-based detection
|
|
name_lower = game_name.lower()
|
|
|
|
if any(x in name_lower for x in ['source', 'half-life', 'counter-strike']):
|
|
return 'Source Engine'
|
|
elif any(x in name_lower for x in ['unreal', 'squad', 'insurgency']):
|
|
return 'Unreal Engine 4'
|
|
elif any(x in name_lower for x in ['unity', 'rust', 'cities']):
|
|
return 'Unity'
|
|
elif 'arma' in name_lower:
|
|
return 'Real Virtuality 4'
|
|
elif any(x in name_lower for x in ['minecraft', 'java']):
|
|
return 'Java'
|
|
elif any(x in name_lower for x in ['quake', 'doom', 'id tech']):
|
|
return 'id Tech'
|
|
else:
|
|
return 'Custom/Unknown'
|
|
|
|
def get_app_id(self, game_name):
|
|
"""Get Steam App ID for the game"""
|
|
return self.appid_mapping.get(game_name, 'N/A')
|
|
|
|
def supports_workshop(self, game_name):
|
|
"""Determine if game supports Steam Workshop"""
|
|
# Games known to support Workshop
|
|
workshop_games = [
|
|
'Counter-Strike 2', 'Counter-Strike: Global Offensive', 'Team Fortress 2',
|
|
'Garry\'s Mod', 'ARK: Survival Evolved', 'Rust', 'Squad',
|
|
'DayZ', 'ARMA 3', 'Killing Floor 2', 'Insurgency: Sandstorm',
|
|
'Cities: Skylines', 'Civilization VI', 'Europa Universalis IV',
|
|
'Stellaris', 'Hearts of Iron IV', 'Prison Architect'
|
|
]
|
|
|
|
return game_name in workshop_games
|
|
|
|
def generate_port_table(self, game_name, engine):
|
|
"""Generate port mapping table based on game type"""
|
|
ports = []
|
|
|
|
# Common patterns based on engine/game type
|
|
if 'Source' in engine:
|
|
ports = [
|
|
{'feature': 'Game Port', 'port': '27015', 'protocol': 'UDP', 'relation': 'Primary', 'notes': 'Main game traffic'},
|
|
{'feature': 'RCON', 'port': '27015', 'protocol': 'TCP', 'relation': 'Same as game', 'notes': 'Remote administration'},
|
|
{'feature': 'SourceTV', 'port': '27020', 'protocol': 'UDP', 'relation': 'Game + 5', 'notes': 'Spectator broadcasting'},
|
|
{'feature': 'Steam Query', 'port': '27016', 'protocol': 'UDP', 'relation': 'Game + 1', 'notes': 'Server browser queries'},
|
|
{'feature': 'Steam Master', 'port': '27011', 'protocol': 'UDP', 'relation': 'Fixed', 'notes': 'Steam master server communication'}
|
|
]
|
|
elif 'Unity' in engine:
|
|
ports = [
|
|
{'feature': 'Game Port', 'port': '7777', 'protocol': 'UDP', 'relation': 'Primary', 'notes': 'Main game traffic'},
|
|
{'feature': 'Query Port', 'port': '7778', 'protocol': 'UDP', 'relation': 'Game + 1', 'notes': 'Server status queries'},
|
|
{'feature': 'Steam Port', 'port': '8766', 'protocol': 'UDP', 'relation': 'Fixed', 'notes': 'Steam integration'},
|
|
{'feature': 'RCON', 'port': '8080', 'protocol': 'TCP', 'relation': 'Configurable', 'notes': 'Web-based admin interface'}
|
|
]
|
|
elif 'Unreal' in engine:
|
|
ports = [
|
|
{'feature': 'Game Port', 'port': '7777', 'protocol': 'UDP', 'relation': 'Primary', 'notes': 'Main game traffic'},
|
|
{'feature': 'Query Port', 'port': '27015', 'protocol': 'UDP', 'relation': 'Configurable', 'notes': 'Server browser queries'},
|
|
{'feature': 'RCON', 'port': '27020', 'protocol': 'TCP', 'relation': 'Configurable', 'notes': 'Remote console access'},
|
|
{'feature': 'Beacon Port', 'port': '15000', 'protocol': 'UDP', 'relation': 'Fixed', 'notes': 'LAN discovery'}
|
|
]
|
|
else:
|
|
# Generic ports for unknown engines
|
|
ports = [
|
|
{'feature': 'Game Port', 'port': '25565', 'protocol': 'UDP', 'relation': 'Primary', 'notes': 'Main game traffic'},
|
|
{'feature': 'Query Port', 'port': '25566', 'protocol': 'UDP', 'relation': 'Game + 1', 'notes': 'Server status queries'},
|
|
{'feature': 'RCON', 'port': '25575', 'protocol': 'TCP', 'relation': 'Game + 10', 'notes': 'Remote administration'}
|
|
]
|
|
|
|
# Format as table
|
|
table_rows = []
|
|
for port in ports:
|
|
table_rows.append(f"| {port['feature']} | {port['port']} | {port['protocol']} | {port['relation']} | {port['notes']} |")
|
|
|
|
return '\n'.join(table_rows)
|
|
|
|
def generate_startup_flags(self, game_name, engine):
|
|
"""Generate exhaustive startup parameters based on game engine"""
|
|
flags = []
|
|
|
|
if 'Source' in engine:
|
|
flags = [
|
|
('+map', '', 'string', 'Starting map name', '+map de_dust2'),
|
|
('+maxplayers', '16', 'integer 1-64', 'Maximum player count', '+maxplayers 32'),
|
|
('+sv_password', '', 'string', 'Server password for private games', '+sv_password mypass123'),
|
|
('+hostname', '', 'string', 'Server name in browser', '+hostname "My Server"'),
|
|
('+rcon_password', '', 'string', 'RCON administration password', '+rcon_password adminpass'),
|
|
('+sv_setsteamaccount', '', 'string', 'Game Server Login Token', '+sv_setsteamaccount TOKEN'),
|
|
('-port', '27015', 'integer 1024-65535', 'Primary server port', '-port 27015'),
|
|
('-tickrate', '64', 'integer 33-128', 'Server update frequency', '-tickrate 128'),
|
|
('-threads', '0', 'integer 0-8', 'Thread count (0=auto)', '-threads 4'),
|
|
('+fps_max', '300', 'integer 33-1000', 'Maximum server FPS', '+fps_max 500'),
|
|
('-nohltv', '', 'flag', 'Disable SourceTV', '-nohltv'),
|
|
('-insecure', '', 'flag', 'Disable VAC (NOT recommended)', '-insecure'),
|
|
('-console', '', 'flag', 'Enable console output', '-console'),
|
|
('-condebug', '', 'flag', 'Log console to file', '-condebug'),
|
|
('+log', 'on', 'on/off', 'Enable server logging', '+log on'),
|
|
('+sv_lan', '0', 'integer 0-1', 'LAN only mode', '+sv_lan 0'),
|
|
('+sv_cheats', '0', 'integer 0-1', 'Enable cheat commands', '+sv_cheats 0'),
|
|
('+mp_autoteambalance', '1', 'integer 0-1', 'Auto-balance teams', '+mp_autoteambalance 1'),
|
|
('+mp_limitteams', '2', 'integer 0-30', 'Team size difference limit', '+mp_limitteams 1'),
|
|
('+sv_pure', '1', 'integer 0-2', 'File consistency checking', '+sv_pure 1')
|
|
]
|
|
elif 'Unity' in engine or 'Rust' in game_name:
|
|
flags = [
|
|
('+server.port', '28015', 'integer 1024-65535', 'Server port', '+server.port 28015'),
|
|
('+server.maxplayers', '100', 'integer 1-300', 'Maximum players', '+server.maxplayers 200'),
|
|
('+server.hostname', '', 'string', 'Server name', '+server.hostname "My Server"'),
|
|
('+server.description', '', 'string', 'Server description', '+server.description "Welcome!"'),
|
|
('+server.password', '', 'string', 'Server password', '+server.password secretpass'),
|
|
('+server.worldsize', '4000', 'integer 1000-8000', 'World map size', '+server.worldsize 3000'),
|
|
('+server.seed', '0', 'integer', 'World generation seed', '+server.seed 12345'),
|
|
('+server.saveinterval', '300', 'integer 60-3600', 'Auto-save interval (seconds)', '+server.saveinterval 600'),
|
|
('+rcon.port', '28016', 'integer 1024-65535', 'RCON port', '+rcon.port 28016'),
|
|
('+rcon.password', '', 'string', 'RCON password', '+rcon.password rconpass'),
|
|
('+rcon.web', '1', 'integer 0-1', 'Enable web RCON', '+rcon.web 1'),
|
|
('+server.encryption', '2', 'integer 0-2', 'Anti-cheat level', '+server.encryption 2'),
|
|
('+fps.limit', '256', 'integer 60-1000', 'Server FPS limit', '+fps.limit 120'),
|
|
('+gc.buffer', '256', 'integer 64-2048', 'Garbage collection buffer (MB)', '+gc.buffer 512'),
|
|
('+server.pve', 'false', 'true/false', 'Player vs Environment mode', '+server.pve false'),
|
|
('+decay.scale', '1.0', 'float 0.0-10.0', 'Building decay multiplier', '+decay.scale 0.5'),
|
|
('+craft.instant', 'false', 'true/false', 'Instant crafting', '+craft.instant false'),
|
|
('+gather.rate', '1.0', 'float 0.1-50.0', 'Resource gather multiplier', '+gather.rate 2.0'),
|
|
('+server.tickrate', '30', 'integer 10-60', 'Server tick rate', '+server.tickrate 30'),
|
|
('+server.stability', 'true', 'true/false', 'Building stability checks', '+server.stability true')
|
|
]
|
|
elif 'Unreal' in engine:
|
|
flags = [
|
|
('Port', '7777', 'integer 1024-65535', 'Server port', 'Port=7777'),
|
|
('QueryPort', '27015', 'integer 1024-65535', 'Query port', 'QueryPort=27015'),
|
|
('MaxPlayers', '80', 'integer 1-100', 'Maximum players', 'MaxPlayers=50'),
|
|
('ServerName', '', 'string', 'Server name in browser', 'ServerName="My Server"'),
|
|
('ServerPassword', '', 'string', 'Password for private server', 'ServerPassword=pass123'),
|
|
('AdminPassword', '', 'string', 'Admin console password', 'AdminPassword=adminpass'),
|
|
('Random', 'NONE', 'NONE/FULL/LIMITED', 'Random map selection', 'Random=LIMITED'),
|
|
('NumReservedSlots', '0', 'integer 0-20', 'Reserved admin slots', 'NumReservedSlots=2'),
|
|
('ShouldAdvertise', 'true', 'true/false', 'Advertise on server browser', 'ShouldAdvertise=true'),
|
|
('IsLANMatch', 'false', 'true/false', 'LAN only mode', 'IsLANMatch=false'),
|
|
('AllowCheats', 'false', 'true/false', 'Enable cheat commands', 'AllowCheats=false'),
|
|
('RecordDemos', 'false', 'true/false', 'Record demo files', 'RecordDemos=true'),
|
|
('PublicQueue', 'true', 'true/false', 'Use public matchmaking', 'PublicQueue=true'),
|
|
('AllowTeamChange', 'true', 'true/false', 'Allow players to switch teams', 'AllowTeamChange=true'),
|
|
('TKAutoKick', 'true', 'true/false', 'Auto-kick team killers', 'TKAutoKick=true'),
|
|
('PreventTeamKill', 'true', 'true/false', 'Prevent team killing', 'PreventTeamKill=false'),
|
|
('UnlimitedAmmo', 'false', 'true/false', 'Infinite ammunition', 'UnlimitedAmmo=false'),
|
|
('MapRotation', '', 'string', 'Map rotation list', 'MapRotation=Map1,Map2,Map3'),
|
|
('RoundTime', '300', 'integer 60-3600', 'Round duration (seconds)', 'RoundTime=600'),
|
|
('LogToFile', 'true', 'true/false', 'Enable file logging', 'LogToFile=true')
|
|
]
|
|
else:
|
|
# Generic flags for other engines
|
|
flags = [
|
|
('--port', '25565', 'integer 1024-65535', 'Server port', '--port 25565'),
|
|
('--max-players', '20', 'integer 1-1000', 'Maximum players', '--max-players 50'),
|
|
('--server-name', '', 'string', 'Server name', '--server-name "My Server"'),
|
|
('--password', '', 'string', 'Server password', '--password mypass'),
|
|
('--admin-password', '', 'string', 'Admin password', '--admin-password adminpass'),
|
|
('--world-size', 'medium', 'small/medium/large', 'World generation size', '--world-size large'),
|
|
('--difficulty', 'normal', 'easy/normal/hard', 'Game difficulty', '--difficulty hard'),
|
|
('--pvp', 'true', 'true/false', 'Player vs Player enabled', '--pvp false'),
|
|
('--auto-save', '300', 'integer 60-3600', 'Auto-save interval', '--auto-save 600'),
|
|
('--log-level', 'info', 'debug/info/warn/error', 'Logging verbosity', '--log-level debug'),
|
|
('--console', '', 'flag', 'Enable console interface', '--console'),
|
|
('--dedicated', '', 'flag', 'Run as dedicated server', '--dedicated'),
|
|
('--no-pause', '', 'flag', 'Disable pause when unfocused', '--no-pause'),
|
|
('--headless', '', 'flag', 'Run without graphics', '--headless'),
|
|
('--threads', '0', 'integer 0-16', 'Worker thread count', '--threads 4')
|
|
]
|
|
|
|
# Format as table
|
|
table_rows = []
|
|
for flag_data in flags:
|
|
flag, default, type_info, desc, example = flag_data
|
|
table_rows.append(f"| {flag} | {default} | {type_info} | {desc} | {example} |")
|
|
|
|
return '\n'.join(table_rows)
|
|
|
|
def generate_config_files_section(self, game_name, engine):
|
|
"""Generate configuration files section"""
|
|
configs = []
|
|
|
|
if 'Source' in engine:
|
|
configs = [
|
|
"### Core Configuration Files",
|
|
"",
|
|
"**Windows Paths:**",
|
|
"- `server.cfg` - Main server configuration",
|
|
"- `autoexec.cfg` - Auto-executed commands on startup",
|
|
"- `banned_user.cfg` - Banned Steam IDs",
|
|
"- `banned_ip.cfg` - Banned IP addresses",
|
|
"- `listip.cfg` - Reserved slot IP addresses",
|
|
"- `mapcycle.txt` - Map rotation list",
|
|
"- `motd.txt` - Message of the day",
|
|
"- `admins_simple.ini` - Simple admin system (SourceMod)",
|
|
"",
|
|
"**Linux Paths:**",
|
|
"- `/opt/steamcmd/servers/{}/cfg/server.cfg`",
|
|
"- `/opt/steamcmd/servers/{}/cfg/autoexec.cfg`",
|
|
"- `/opt/steamcmd/servers/{}/addons/sourcemod/configs/`",
|
|
""
|
|
]
|
|
elif 'Unity' in engine or 'Rust' in game_name:
|
|
configs = [
|
|
"### Core Configuration Files",
|
|
"",
|
|
"**Windows Paths:**",
|
|
"- `server.cfg` - Main server settings",
|
|
"- `users.cfg` - User permissions and data",
|
|
"- `bans.cfg` - Banned players list",
|
|
"- `oxide/config/` - Plugin configurations",
|
|
"- `oxide/data/` - Plugin data storage",
|
|
"- `oxide/logs/` - Server and plugin logs",
|
|
"",
|
|
"**Linux Paths:**",
|
|
"- `~/.steam/steamapps/common/rust_dedicated/server.cfg`",
|
|
"- `~/.steam/steamapps/common/rust_dedicated/oxide/`",
|
|
""
|
|
]
|
|
else:
|
|
configs = [
|
|
"### Configuration Files",
|
|
"",
|
|
"**Windows Paths:**",
|
|
"- `server.properties` - Main server configuration",
|
|
"- `whitelist.json` - Allowed players list",
|
|
"- `ops.json` - Server operators list",
|
|
"- `banned-players.json` - Banned players",
|
|
"- `banned-ips.json` - Banned IP addresses",
|
|
"- `server-icon.png` - Server icon (64x64)",
|
|
"",
|
|
"**Linux Paths:**",
|
|
"- `/opt/gameserver/server.properties`",
|
|
"- `/opt/gameserver/world/` - World save files",
|
|
""
|
|
]
|
|
|
|
return '\n'.join(configs)
|
|
|
|
def generate_workshop_section(self, game_name, supports_workshop):
|
|
"""Generate Steam Workshop integration section"""
|
|
if not supports_workshop:
|
|
return "This game does not support Steam Workshop integration."
|
|
|
|
return """### Steam Workshop Setup
|
|
|
|
**Authentication Requirements:**
|
|
- Valid Steam account with game ownership
|
|
- Game Server Login Token (GSLT) from Steam
|
|
- Proper Steam API authentication
|
|
|
|
**Workshop Collection Management:**
|
|
1. Create Workshop collection in Steam client
|
|
2. Note the collection ID from the URL
|
|
3. Configure server to subscribe to collection
|
|
4. Set up automatic updates for Workshop content
|
|
|
|
**File Locations:**
|
|
|
|
**Windows:**
|
|
- Workshop cache: `%USERPROFILE%/.steam/steamapps/workshop/content/<appid>/`
|
|
- Server workshop: `steamapps/workshop/content/<appid>/`
|
|
|
|
**Linux:**
|
|
- Workshop cache: `~/.steam/steamapps/workshop/content/<appid>/`
|
|
- Server workshop: `steamapps/workshop/content/<appid>/`
|
|
|
|
**Common Workshop Commands:**
|
|
```bash
|
|
# Subscribe to workshop item
|
|
workshop.download <item_id>
|
|
|
|
# Update all subscribed items
|
|
workshop.update
|
|
|
|
# List subscribed items
|
|
workshop.list
|
|
|
|
# Unsubscribe from item
|
|
workshop.unsubscribe <item_id>
|
|
```
|
|
|
|
**Troubleshooting Workshop Issues:**
|
|
- Verify GSLT is valid and not expired
|
|
- Check Steam API connectivity
|
|
- Clear workshop cache if downloads fail
|
|
- Ensure sufficient disk space for content
|
|
- Monitor workshop update logs for errors"""
|
|
|
|
def generate_sample_configs(self, game_name, engine):
|
|
"""Generate sample configuration files"""
|
|
if 'Source' in engine:
|
|
return """### server.cfg Example
|
|
```
|
|
// Server identification
|
|
hostname "My Self-Hosted Server"
|
|
sv_password ""
|
|
rcon_password "secure_rcon_password_here"
|
|
|
|
// Game settings
|
|
mp_maxrounds 30
|
|
mp_timelimit 0
|
|
mp_roundtime 115
|
|
mp_buytime 15
|
|
mp_startmoney 800
|
|
|
|
// Server behavior
|
|
sv_cheats 0
|
|
sv_pure 1
|
|
sv_lan 0
|
|
sv_region 255
|
|
|
|
// Network optimization
|
|
sv_maxcmdrate 128
|
|
sv_maxupdaterate 128
|
|
sv_minrate 20000
|
|
sv_maxrate 100000
|
|
|
|
// Logging
|
|
log on
|
|
sv_logbans 1
|
|
sv_logecho 1
|
|
sv_logfile 1
|
|
sv_log_onefile 0
|
|
```"""
|
|
else:
|
|
return """### server.properties Example
|
|
```
|
|
# Basic server settings
|
|
server-name=My Self-Hosted Server
|
|
server-port=25565
|
|
max-players=20
|
|
gamemode=survival
|
|
difficulty=normal
|
|
|
|
# World settings
|
|
level-name=world
|
|
level-seed=
|
|
level-type=default
|
|
spawn-protection=16
|
|
|
|
# Network settings
|
|
enable-query=true
|
|
query.port=25565
|
|
enable-rcon=true
|
|
rcon.port=25575
|
|
rcon.password=secure_password_here
|
|
|
|
# Performance
|
|
view-distance=10
|
|
simulation-distance=10
|
|
max-tick-time=60000
|
|
```"""
|
|
|
|
def generate_game_guide(self, game_name):
|
|
"""Generate complete guide for a single game"""
|
|
template = self.load_template()
|
|
|
|
# Gather game information
|
|
engine = self.detect_engine(game_name)
|
|
app_id = self.get_app_id(game_name)
|
|
workshop_support = self.supports_workshop(game_name)
|
|
|
|
# Generate content sections
|
|
port_table = self.generate_port_table(game_name, engine)
|
|
startup_flags_table = self.generate_startup_flags(game_name, engine)
|
|
config_files_section = self.generate_config_files_section(game_name, engine)
|
|
workshop_section = self.generate_workshop_section(game_name, workshop_support)
|
|
sample_configs = self.generate_sample_configs(game_name, engine)
|
|
|
|
# Template replacements
|
|
replacements = {
|
|
'{game_name}': game_name,
|
|
'{engine_type}': engine,
|
|
'{workshop_support_text}': 'supports' if workshop_support else 'does not support',
|
|
'{port_table}': port_table,
|
|
'{startup_flags_table}': startup_flags_table,
|
|
'{config_files_section}': config_files_section,
|
|
'{workshop_section}': workshop_section,
|
|
'{sample_configs}': sample_configs,
|
|
'{windows_command}': f'server.exe +map de_dust2 +maxplayers 16',
|
|
'{linux_command}': f'./server +map de_dust2 +maxplayers 16',
|
|
'{basic_command}': f'./server --dedicated --console',
|
|
'{production_command}': f'./server --dedicated --console --max-players 50 --auto-save 300',
|
|
'{performance_command}': f'./server --dedicated --headless --threads 4 --max-players 100',
|
|
'{windows_batch_example}': f'server.exe --dedicated --console\npause',
|
|
'{linux_service_example}': f'/opt/gameserver/server --dedicated --console'
|
|
}
|
|
|
|
# Apply replacements
|
|
content = template
|
|
for placeholder, replacement in replacements.items():
|
|
content = content.replace(placeholder, replacement)
|
|
|
|
return content
|
|
|
|
def convert_to_pdf(self, markdown_file, pdf_file):
|
|
"""Convert Markdown to PDF using Pandoc"""
|
|
try:
|
|
cmd = [
|
|
'pandoc',
|
|
str(markdown_file),
|
|
'-o', str(pdf_file),
|
|
'--from=gfm',
|
|
'--toc',
|
|
'--toc-depth=3',
|
|
'--number-sections',
|
|
'--pdf-engine=wkhtmltopdf',
|
|
f'--metadata=title:{markdown_file.stem.replace("-", " ").title()} Server Guide',
|
|
'--metadata=author:Self-Hosting Guide',
|
|
f'--metadata=date:{datetime.now().strftime("%Y-%m-%d")}'
|
|
]
|
|
|
|
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
|
|
|
if result.returncode == 0:
|
|
print(f"✓ PDF generated: {pdf_file.name}")
|
|
return True
|
|
else:
|
|
print(f"✗ PDF generation failed for {markdown_file.name}")
|
|
print(f" Error: {result.stderr}")
|
|
return False
|
|
|
|
except subprocess.TimeoutExpired:
|
|
print(f"✗ PDF generation timed out for {markdown_file.name}")
|
|
return False
|
|
except Exception as e:
|
|
print(f"✗ PDF generation error for {markdown_file.name}: {e}")
|
|
return False
|
|
|
|
def validate_content(self, content, game_name):
|
|
"""Validate content for forbidden terms and requirements"""
|
|
issues = []
|
|
|
|
# Check for forbidden provider mentions
|
|
content_lower = content.lower()
|
|
for term in self.forbidden_terms:
|
|
if term.lower() in content_lower:
|
|
issues.append(f"Contains forbidden term: {term}")
|
|
|
|
# Check for HTML entity escapes
|
|
if '<' in content or '>' in content:
|
|
issues.append("Contains HTML entity escapes (<, >)")
|
|
|
|
# Check for required sections
|
|
required_sections = [
|
|
'## Overview', '## System Requirements', '## Ports & Networking',
|
|
'## Startup Parameters', '## Configuration Files', '## Troubleshooting'
|
|
]
|
|
|
|
for section in required_sections:
|
|
if section not in content:
|
|
issues.append(f"Missing required section: {section}")
|
|
|
|
if issues:
|
|
print(f"⚠️ Validation issues for {game_name}:")
|
|
for issue in issues:
|
|
print(f" - {issue}")
|
|
return False
|
|
|
|
return True
|
|
|
|
def generate_all_guides(self):
|
|
"""Generate guides for all games in CSV"""
|
|
games = self.read_csv_games()
|
|
|
|
if not games:
|
|
print("No games found in CSV file")
|
|
return False
|
|
|
|
print(f"\nGenerating guides for {len(games)} games...")
|
|
print("=" * 50)
|
|
|
|
generated_md = 0
|
|
generated_pdf = 0
|
|
failed_games = []
|
|
|
|
for game in games:
|
|
game_name = game['name']
|
|
sanitized_name = self.sanitize_filename(game_name)
|
|
|
|
print(f"\nProcessing: {game_name}")
|
|
|
|
try:
|
|
# Generate Markdown content
|
|
content = self.generate_game_guide(game_name)
|
|
|
|
# Validate content
|
|
if not self.validate_content(content, game_name):
|
|
failed_games.append(f"{game_name} (validation failed)")
|
|
continue
|
|
|
|
# Write Markdown file
|
|
md_file = self.output_md / f"{sanitized_name}.md"
|
|
with open(md_file, 'w', encoding='utf-8') as f:
|
|
f.write(content)
|
|
print(f" ✓ Markdown: {md_file.name}")
|
|
generated_md += 1
|
|
|
|
# Generate PDF
|
|
pdf_file = self.output_pdf / f"{sanitized_name}.pdf"
|
|
if self.convert_to_pdf(md_file, pdf_file):
|
|
generated_pdf += 1
|
|
else:
|
|
failed_games.append(f"{game_name} (PDF generation failed)")
|
|
|
|
except Exception as e:
|
|
print(f" ✗ Error processing {game_name}: {e}")
|
|
failed_games.append(f"{game_name} (processing error)")
|
|
|
|
# Generate summary
|
|
print("\n" + "=" * 50)
|
|
print("GENERATION SUMMARY")
|
|
print("=" * 50)
|
|
print(f"Games processed: {len(games)}")
|
|
print(f"Markdown files generated: {generated_md}")
|
|
print(f"PDF files generated: {generated_pdf}")
|
|
print(f"Failed games: {len(failed_games)}")
|
|
|
|
if failed_games:
|
|
print(f"\nFailed games:")
|
|
for failed in failed_games:
|
|
print(f" - {failed}")
|
|
|
|
# Save generation manifest
|
|
manifest = {
|
|
"generated_at": datetime.now().isoformat(),
|
|
"total_games": len(games),
|
|
"markdown_generated": generated_md,
|
|
"pdfs_generated": generated_pdf,
|
|
"failed_games": failed_games,
|
|
"games": [{"name": g["name"], "sanitized": self.sanitize_filename(g["name"])} for g in games]
|
|
}
|
|
|
|
manifest_file = self.output_pdf / "manifest.json"
|
|
with open(manifest_file, 'w', encoding='utf-8') as f:
|
|
json.dump(manifest, f, indent=2)
|
|
print(f"\nManifest saved: {manifest_file}")
|
|
|
|
return len(failed_games) == 0
|
|
|
|
if __name__ == "__main__":
|
|
generator = GameGuideGenerator()
|
|
success = generator.generate_all_guides()
|
|
sys.exit(0 if success else 1) |