docker-allthemods10/fulfillment.py

290 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
fulfillment.py — Sulkta shop order fulfillment daemon
Runs alongside the MC server inside the Docker container.
Polls the shop API for confirmed orders, delivers items via RCON,
marks orders fulfilled. Retries each poll cycle if the player is offline.
"""
import os, socket, struct, time, json, logging, signal, sys
import urllib.request, urllib.parse, urllib.error
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [fulfillment] %(levelname)s %(message)s',
datefmt='%Y-%m-%d %H:%M:%S',
stream=sys.stdout,
)
log = logging.getLogger(__name__)
SHOP_API_URL = os.environ.get('SHOP_API_URL', '').rstrip('/')
SHOP_API_KEY = os.environ.get('SHOP_API_KEY', '')
RCON_HOST = os.environ.get('RCON_HOST', '127.0.0.1')
RCON_PORT = int(os.environ.get('RCON_PORT', '25575'))
RCON_PASSWORD = os.environ.get('RCON_PASSWORD', '')
POLL_INTERVAL = int(os.environ.get('FULFILLMENT_POLL_INTERVAL', '30'))
STARTUP_DELAY = int(os.environ.get('FULFILLMENT_STARTUP_DELAY', '90'))
# ── Item delivery definitions ─────────────────────────────────────────────────
# {player} is replaced with the Minecraft username at delivery time.
ITEM_COMMANDS = {
'starter-kit': [
'/give {player} minecraft:diamond_pickaxe 1',
'/give {player} minecraft:diamond_axe 1',
'/give {player} minecraft:diamond_shovel 1',
'/give {player} minecraft:diamond_sword 1',
'/give {player} minecraft:diamond_helmet 1',
'/give {player} minecraft:diamond_chestplate 1',
'/give {player} minecraft:diamond_leggings 1',
'/give {player} minecraft:diamond_boots 1',
'/give {player} minecraft:cooked_beef 64',
'/give {player} minecraft:iron_ingot 64',
'/give {player} minecraft:gold_ingot 32',
'/give {player} minecraft:diamond 16',
'/give {player} minecraft:torch 64',
],
'reactor-kit': [
# Shell — 32 casings covers a 3×3×4 exterior comfortably
'/give {player} mekanism:fission_reactor_casing 32',
# Optional glass windows
'/give {player} mekanism:fission_reactor_glass 8',
# Ports: 2× coolant in/out + 1× heated coolant out + 1× waste drain
'/give {player} mekanism:fission_reactor_port 4',
# Logic adapter to arm/disarm the reactor
'/give {player} mekanism:fission_reactor_logic_adapter 1',
# Fuel assembly column + control rods
'/give {player} mekanism:fission_fuel_assembly 4',
'/give {player} mekanism:control_rod_assembly 4',
# Uranium — raw ore to process into fissile fuel
'/give {player} mekanism:yellow_cake_uranium 64',
],
}
# ── Minimal RCON client ───────────────────────────────────────────────────────
class RCONError(Exception):
pass
class RCON:
AUTH = 3
AUTH_RESPONSE = 2
EXEC = 2
RESPONSE = 0
def __init__(self, host, port, password, timeout=10):
self.host = host
self.port = port
self.password = password
self.timeout = timeout
self._sock = None
self._req_id = 0
def connect(self):
self._sock = socket.create_connection((self.host, self.port), timeout=self.timeout)
self._sock.settimeout(self.timeout)
self._authenticate()
def close(self):
if self._sock:
try:
self._sock.close()
except Exception:
pass
self._sock = None
def __enter__(self):
self.connect()
return self
def __exit__(self, *_):
self.close()
def _send(self, type_, payload):
self._req_id += 1
body = payload.encode('utf-8') + b'\x00\x00'
pkt = struct.pack('<iii', len(body) + 8, self._req_id, type_) + body
self._sock.sendall(pkt)
return self._req_id
def _recv(self):
def read(n):
buf = b''
while len(buf) < n:
chunk = self._sock.recv(n - len(buf))
if not chunk:
raise RCONError('Connection closed by server')
buf += chunk
return buf
size = struct.unpack('<i', read(4))[0]
data = read(size)
req_id, type_ = struct.unpack('<ii', data[:8])
payload = data[8:-2].decode('utf-8', errors='replace')
return req_id, type_, payload
def _authenticate(self):
self._send(self.AUTH, self.password)
req_id, _, _ = self._recv()
if req_id == -1:
raise RCONError('RCON authentication failed — wrong password?')
def command(self, cmd):
self._send(self.EXEC, cmd)
_, _, payload = self._recv()
return payload.strip()
# ── API helpers ───────────────────────────────────────────────────────────────
def api_get(action, extra_params=''):
url = f'{SHOP_API_URL}/api.php?action={action}&key={urllib.parse.quote(SHOP_API_KEY)}'
if extra_params:
url += '&' + extra_params
req = urllib.request.Request(url, headers={'Accept': 'application/json', 'User-Agent': 'sulkta-fulfillment/1.0'})
with urllib.request.urlopen(req, timeout=15) as r:
return json.loads(r.read())
def api_post(action, data):
url = f'{SHOP_API_URL}/api.php?action={action}'
body = json.dumps({**data, 'key': SHOP_API_KEY}).encode()
req = urllib.request.Request(
url, data=body,
headers={'Content-Type': 'application/json', 'User-Agent': 'sulkta-fulfillment/1.0'},
)
with urllib.request.urlopen(req, timeout=15) as r:
return json.loads(r.read())
# ── Fulfillment logic ─────────────────────────────────────────────────────────
def get_online_players(rcon):
"""Returns a set of currently online player names."""
resp = rcon.command('/list')
# Vanilla/NeoForge: "There are N of a max of M players online: name1, name2"
if ':' not in resp:
return set()
names_part = resp.split(':', 1)[1].strip()
if not names_part:
return set()
return {n.strip() for n in names_part.split(',') if n.strip()}
def deliver_order(rcon, order):
"""
Runs all RCON give commands for an order.
Returns (success: bool, message: str).
"""
item_id = order['item_id']
player = order['minecraft_username']
commands = ITEM_COMMANDS.get(item_id)
if not commands:
return False, f'No delivery definition for item_id={item_id}'
errors = []
for cmd in commands:
filled = cmd.replace('{player}', player)
try:
result = rcon.command(filled)
log.debug('RCON %-60s%s', filled, result)
# MC returns "Given [item]×N to PlayerName" on success.
# Any 'error' or 'unknown' in the response is a problem.
if result.lower().startswith('unknown') or 'error' in result.lower():
errors.append(f'{filled}: {result}')
except Exception as e:
errors.append(f'{filled}: RCON error: {e}')
if errors:
log.warning('Delivery errors for order %s: %s', order['order_id'], errors)
return False, '; '.join(errors)
return True, f'Delivered {len(commands)} command(s) via RCON'
def process_orders():
if not SHOP_API_URL or not SHOP_API_KEY:
log.debug('SHOP_API_URL / SHOP_API_KEY not set — skipping poll')
return
if not RCON_PASSWORD:
log.debug('RCON_PASSWORD not set — skipping poll')
return
# Fetch pending orders from Rackham
try:
resp = api_get('pending_fulfillment')
orders = resp.get('orders', [])
except Exception as e:
log.warning('Failed to fetch pending orders: %s', e)
return
if not orders:
return
log.info('%d confirmed order(s) to process', len(orders))
# Open RCON connection
try:
rcon = RCON(RCON_HOST, RCON_PORT, RCON_PASSWORD)
rcon.connect()
except Exception as e:
log.warning('RCON connect failed (%s) — will retry next cycle', e)
return
try:
online = get_online_players(rcon)
if online:
log.info('Online players: %s', ', '.join(sorted(online)))
else:
log.info('No players currently online')
for order in orders:
player = order['minecraft_username']
if player not in online:
log.info('Order %s: %s is offline, will retry next cycle', order['order_id'], player)
continue
log.info('Delivering order %s (%s) to %s', order['order_id'], order['item_id'], player)
success, msg = deliver_order(rcon, order)
try:
api_post('mark_fulfilled', {
'order_id': order['order_id'],
'success': success,
'message': msg,
})
status = 'fulfilled' if success else 'failed'
log.info('Order %s%s: %s', order['order_id'], status, msg)
except Exception as e:
log.warning('Failed to mark order %s: %s', order['order_id'], e)
finally:
rcon.close()
# ── Main ──────────────────────────────────────────────────────────────────────
def main():
log.info('Sulkta fulfillment daemon starting')
log.info('Shop API : %s', SHOP_API_URL or '(not configured — set SHOP_API_URL)')
log.info('RCON : %s:%d', RCON_HOST, RCON_PORT)
log.info('Poll : every %ds', POLL_INTERVAL)
if STARTUP_DELAY > 0:
log.info('Waiting %ds for MC server to start…', STARTUP_DELAY)
time.sleep(STARTUP_DELAY)
def shutdown(sig, frame):
log.info('Shutting down (signal %d)', sig)
sys.exit(0)
signal.signal(signal.SIGTERM, shutdown)
signal.signal(signal.SIGINT, shutdown)
while True:
try:
process_orders()
except Exception as e:
log.error('Unhandled error in poll cycle: %s', e, exc_info=True)
time.sleep(POLL_INTERVAL)
if __name__ == '__main__':
main()