fulfillment: add RCON order fulfillment daemon (starter-kit, reactor-kit)
This commit is contained in:
parent
ccb66d2cb1
commit
0463da19ed
1 changed files with 290 additions and 0 deletions
290
fulfillment.py
Normal file
290
fulfillment.py
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
#!/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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue