"""Web interface for City Resource Optimization solver.""" from flask import Flask, render_template, request, jsonify from ortools.sat.python import cp_model import json import solve import io import sys from contextlib import redirect_stdout from constraint_validator import make_constraint_lambda app = Flask(__name__) @app.route("/") def index(): """Serve the solver form.""" return render_template( "solver.html", initial=solve.INITIAL, actions=solve.ENABLED_ACTIONS, agents=solve.AGENT_AVAILABILITY, num_steps=solve.NUM_STEPS, objective_factors=solve.OBJECTIVE_FACTORS, objective_mode=solve.OBJECTIVE_MODE, ) @app.route("/solve", methods=["POST"]) def solve_handler(): """Handle solver requests from the form.""" try: data = request.get_json() # Parse initial resources (divide by 10 since UI shows unscaled values) # R = Renown, L = Luxuries, X = Express tickets (default 0; no sources yet) initial_defaults = {"E": 3, "B": 3, "S": 3, "C": 3, "R": 0, "L": 0, "X": 0} initial = tuple( int(float(data.get(f"initial_{r}", d)) * 10) for r, d in initial_defaults.items() ) # Parse per-city arrivals and departures (dynamic number of cities) arrivals = {} for step in range(1, solve.NUM_STEPS + 1): arrivals[step] = [] # Find all cities submitted (city_0_type, city_1_type, etc.) city_indices = set() for key in data.keys(): if key.startswith("city_") and key.endswith("_type"): city_idx = int(key.split("_")[1]) city_indices.add(city_idx) for city_idx in sorted(city_indices): city_type = data.get(f"city_{city_idx}_type") arrival_step_str = data.get(f"city_{city_idx}_arrival_step") departure_step_str = data.get(f"city_{city_idx}_departure_step") adjacent_str = data.get(f"city_{city_idx}_adjacent", "") if city_type and city_type != "none" and arrival_step_str: arrival_step = int(arrival_step_str) departure_step = ( int(departure_step_str) if departure_step_str else solve.NUM_STEPS + 1 ) # Parse adjacent cities (comma-separated, whitespace ignored) adjacent_to = [] if adjacent_str: adjacent_to = [ int(idx.strip()) for idx in adjacent_str.split(",") if idx.strip() ] # Parse base flag (checkbox-style: may arrive as bool or string) base_val = data.get(f"city_{city_idx}_base", False) if isinstance(base_val, str): base_val = base_val.lower() in ("true", "on", "1", "yes") city_data = { "type": city_type, "adjacent_to": adjacent_to, "base": bool(base_val), "departure_step": departure_step, } # Parse vat values for foundries (if specified, use them; otherwise let normalize_city default to 1) if city_type == "F": vat_e = data.get(f"city_{city_idx}_vat_E") vat_b = data.get(f"city_{city_idx}_vat_B") vat_s = data.get(f"city_{city_idx}_vat_S") vats = {} if vat_e: vats["E"] = int(vat_e) if vat_b: vats["B"] = int(vat_b) if vat_s: vats["S"] = int(vat_s) if vats: city_data["vats"] = vats arrivals[arrival_step].append(city_data) # Parse enabled actions enabled_actions = {} for action_name in solve.ENABLED_ACTIONS.keys(): enabled_actions[action_name] = data.get(f"action_{action_name}", False) # Parse agent availability agent_availability = {} for agent_name in solve.AGENT_AVAILABILITY.keys(): steps_str = data.get(f"agent_{agent_name}_steps", "") if steps_str: agent_availability[agent_name] = [ int(s) for s in steps_str.split(",") if s ] else: agent_availability[agent_name] = [] solve.AGENT_AVAILABILITY = agent_availability # Parse time limit time_limit = float(data.get("time_limit", 60.0)) # Parse verbose flag verbose = data.get("verbose", True) # Parse fixed constraints fixed_choices = None fixed_actions_data = data.get("fixed_actions", {}) fixed_governors_data = data.get("fixed_governors", {}) if fixed_actions_data or fixed_governors_data: fixed_choices = {} if fixed_actions_data: fixed_choices["actions"] = {} for key, action in fixed_actions_data.items(): city, step = map(int, key.split(",")) fixed_choices["actions"][(city, step)] = action if fixed_governors_data: fixed_choices["governors"] = {} for key, enabled in fixed_governors_data.items(): city, step, agent = key.rsplit(",", 2) city, step = int(city), int(step) fixed_choices["governors"][(city, step, agent)] = enabled # Parse objective (factor = exponent in "product" mode, weight in # "sum" mode; missing keys = resource excluded). Factors are NOT x10 # scaled — they apply to the scaled resource totals and the report # descales the objective. None falls back to solve.py's defaults. objective_mode = data.get("objective_mode") or None objective_factors = None objective_factors_data = data.get("objective_factors") if objective_factors_data is not None: # All-zero dict passes through so solve()'s validation rejects it # with a clear error instead of silently using the defaults. objective_factors = {r: int(f) for r, f in objective_factors_data.items()} # Parse resource constraints resource_constraints = None resource_constraints_data = data.get("resource_constraints", []) if resource_constraints_data: resource_constraints = [] for expr in resource_constraints_data: try: constraint_lambda = make_constraint_lambda(expr) resource_constraints.append(constraint_lambda) except Exception as e: raise ValueError(f"Invalid resource constraint '{expr}': {e}") # Capture solver output output_buffer = io.StringIO() with redirect_stdout(output_buffer): solver, status = solve.solve( initial=initial, arrivals=arrivals, max_res=solve.MAX_RES, max_vat=solve.MAX_VAT, # min to avoid bricking stuff # time_limit=min(time_limit, 60.0), time_limit=time_limit, num_workers=8, verbose=verbose, fixed_choices=fixed_choices, resource_constraints=resource_constraints, objective_factors=objective_factors, objective_mode=objective_mode, ) output = output_buffer.getvalue() status_name = solver.StatusName(status) if status else "UNKNOWN" return jsonify({"success": True, "status": status_name, "output": output}) except Exception as e: return jsonify({"success": False, "error": str(e)}), 400 if __name__ == "__main__": app.run(debug=False, host="0.0.0.0", port=5000)