web version initial implementation
This commit is contained in:
parent
591ab5ac4d
commit
20568d2b3e
8 changed files with 2747 additions and 698 deletions
1357
Cargo.lock
generated
1357
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -7,6 +7,8 @@ edition = "2021"
|
||||||
ratatui = "0.29"
|
ratatui = "0.29"
|
||||||
crossterm = "0.28"
|
crossterm = "0.28"
|
||||||
clap = { version = "4.5", features = ["derive"] }
|
clap = { version = "4.5", features = ["derive"] }
|
||||||
|
actix-web = "4.11"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
|
|
|
||||||
10
DEVLOG.md
10
DEVLOG.md
|
|
@ -86,3 +86,13 @@
|
||||||
- Added national-team flag mappings, including `PRC China`.
|
- Added national-team flag mappings, including `PRC China`.
|
||||||
- Added tactic/formation profile mappings for the new national teams.
|
- Added tactic/formation profile mappings for the new national teams.
|
||||||
- Verified with `list` and deterministic `quick` simulation using national teams.
|
- Verified with `list` and deterministic `quick` simulation using national teams.
|
||||||
|
|
||||||
|
## 2026-02-11 - Web mode with Actix and Rust-backed frontend
|
||||||
|
|
||||||
|
### Scope completed
|
||||||
|
- Added `--web` launch mode to start an Actix server at `127.0.0.1:9009`.
|
||||||
|
- Implemented web APIs for team listing and simulation lifecycle:
|
||||||
|
- create/list/detail/start/clone/delete/export CSV
|
||||||
|
- Reused Rust simulation engine and instance lifecycle for backend execution.
|
||||||
|
- Rebuilt `index.html` and `data.js` as a modern web dashboard UI backed by Rust APIs.
|
||||||
|
- Removed legacy client-side simulation engine from the browser path.
|
||||||
|
|
|
||||||
19
README.md
19
README.md
|
|
@ -67,6 +67,24 @@ Use a global seed for reproducibility:
|
||||||
cargo run -- --seed 42
|
cargo run -- --seed 42
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Web mode (Actix)
|
||||||
|
|
||||||
|
Launch the web UI on port `9009`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run -- --web
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://127.0.0.1:9009
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- The web frontend (`index.html` + `data.js`) now uses Rust backend APIs.
|
||||||
|
- Simulation logic runs server-side in Rust (shared with CLI/TUI engine).
|
||||||
|
|
||||||
### Quick match (headless)
|
### Quick match (headless)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -143,6 +161,7 @@ Readable fullscreen data panels:
|
||||||
```text
|
```text
|
||||||
src/
|
src/
|
||||||
├── main.rs # CLI entrypoint and commands
|
├── main.rs # CLI entrypoint and commands
|
||||||
|
├── web.rs # Actix web server + JSON APIs
|
||||||
├── app.rs # App state and event loop
|
├── app.rs # App state and event loop
|
||||||
├── data.rs # Teams, flags, tactics, profiles
|
├── data.rs # Teams, flags, tactics, profiles
|
||||||
├── sim.rs # Match/league/knockout simulation engine
|
├── sim.rs # Match/league/knockout simulation engine
|
||||||
|
|
|
||||||
446
data.js
446
data.js
|
|
@ -1,80 +1,376 @@
|
||||||
// =======================================================
|
const state = {
|
||||||
// SoccerCloud Simulator Data File
|
teams: [],
|
||||||
// Contains all team, flag, and profile information.
|
simulations: [],
|
||||||
// =======================================================
|
selectedDetailId: null,
|
||||||
|
pollHandle: null,
|
||||||
const teams = [
|
|
||||||
// J-League
|
|
||||||
"Kashima Antlers","Urawa Red Diamonds","Gamba Osaka","Cerezo Osaka","Kawasaki Frontale",
|
|
||||||
"Yokohama F. Marinos","Nagoya Grampus","Shimizu S-Pulse","Sanfrecce Hiroshima","Consadole Sapporo",
|
|
||||||
"Ventforet Kofu","Tokyo Verdy","JEF United Chiba",
|
|
||||||
// Euro clubs
|
|
||||||
"Arsenal","FC Barcelona","Real Madrid","Manchester City","Manchester United","Liverpool",
|
|
||||||
"Bayern Munich","Borussia Dortmund","Paris Saint-Germain","Juventus","Inter","AC Milan",
|
|
||||||
"Ajax","Benfica","Porto","Celtic"
|
|
||||||
];
|
|
||||||
|
|
||||||
const TEAM_FLAGS = {
|
|
||||||
// Japan
|
|
||||||
"Kashima Antlers":"🇯🇵","Urawa Red Diamonds":"🇯🇵","Gamba Osaka":"🇯🇵","Cerezo Osaka":"🇯🇵","Kawasaki Frontale":"🇯🇵",
|
|
||||||
"Yokohama F. Marinos":"🇯🇵","Nagoya Grampus":"🇯🇵","Shimizu S-Pulse":"🇯🇵","Sanfrecce Hiroshima":"🇯🇵","Consadole Sapporo":"🇯🇵",
|
|
||||||
"Ventforet Kofu":"🇯🇵","Tokyo Verdy":"🇯🇵", "JEF United Chiba":"🇯🇵",
|
|
||||||
// UK
|
|
||||||
"Arsenal":"🇬🇧","Manchester City":"🇬🇧","Manchester United":"🇬🇧","Liverpool":"🇬🇧","Celtic":"🇬🇧",
|
|
||||||
// Spain
|
|
||||||
"FC Barcelona":"🇪🇸","Real Madrid":"🇪🇸",
|
|
||||||
// Germany
|
|
||||||
"Bayern Munich":"🇩🇪","Borussia Dortmund":"🇩🇪",
|
|
||||||
// France
|
|
||||||
"Paris Saint-Germain":"🇫🇷",
|
|
||||||
// Italy
|
|
||||||
"Juventus":"🇮🇹","Inter":"🇮🇹","AC Milan":"🇮🇹",
|
|
||||||
// Netherlands
|
|
||||||
"Ajax":"🇳🇱",
|
|
||||||
// Portugal
|
|
||||||
"Benfica":"🇵🇹","Porto":"🇵🇹"
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const FORMATIONS = ["4-4-2","4-3-3","4-2-3-1","3-5-2","5-4-1"];
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
const TACTICS = {
|
function setStatus(message) {
|
||||||
counter: { label:"Counter", attackBias:1.10, goalMult:1.08, fastBreak:0.25, foulMult:1.00, blockMult:1.00, pressMult:0.95 },
|
$("statusText").textContent = message;
|
||||||
possession: { label:"Possession", attackBias:1.00, goalMult:0.95, fastBreak:0.10, foulMult:0.90, blockMult:1.00, pressMult:0.90 },
|
}
|
||||||
high_press: { label:"High Press", attackBias:1.15, goalMult:1.00, fastBreak:0.20, foulMult:1.20, blockMult:0.95, pressMult:1.20 },
|
|
||||||
low_block: { label:"Low Block", attackBias:0.92, goalMult:0.92, fastBreak:0.12, foulMult:0.95, blockMult:1.15, pressMult:0.85 },
|
|
||||||
};
|
|
||||||
|
|
||||||
const TEAM_PROFILES = {
|
async function request(path, options = {}) {
|
||||||
// Europe
|
const response = await fetch(path, options);
|
||||||
"Arsenal": { formation:"4-3-3", tactic:"possession" },
|
if (!response.ok) {
|
||||||
"FC Barcelona": { formation:"4-3-3", tactic:"possession" },
|
let msg = `${response.status} ${response.statusText}`;
|
||||||
"Real Madrid": { formation:"4-3-3", tactic:"counter" },
|
try {
|
||||||
"Manchester City": { formation:"4-3-3", tactic:"possession" },
|
const body = await response.json();
|
||||||
"Manchester United": { formation:"4-2-3-1", tactic:"high_press" },
|
if (body && body.error) {
|
||||||
"Liverpool": { formation:"4-3-3", tactic:"high_press" },
|
msg = body.error;
|
||||||
"Bayern Munich": { formation:"4-2-3-1", tactic:"high_press" },
|
}
|
||||||
"Borussia Dortmund": { formation:"4-2-3-1", tactic:"high_press" },
|
} catch (_) {}
|
||||||
"Paris Saint-Germain": { formation:"4-3-3", tactic:"possession" },
|
throw new Error(msg);
|
||||||
"Juventus": { formation:"3-5-2", tactic:"low_block" },
|
}
|
||||||
"Inter": { formation:"3-5-2", tactic:"low_block" },
|
|
||||||
"AC Milan": { formation:"4-2-3-1", tactic:"possession" },
|
|
||||||
"Ajax": { formation:"4-3-3", tactic:"possession" },
|
|
||||||
"Benfica": { formation:"4-2-3-1", tactic:"possession" },
|
|
||||||
"Porto": { formation:"4-4-2", tactic:"counter" },
|
|
||||||
"Celtic": { formation:"4-3-3", tactic:"possession" },
|
|
||||||
// J-League (generic lean)
|
|
||||||
"Kawasaki Frontale": { formation:"4-3-3", tactic:"possession" },
|
|
||||||
"Yokohama F. Marinos": { formation:"4-3-3", tactic:"high_press" },
|
|
||||||
"Kashima Antlers": { formation:"4-4-2", tactic:"counter" },
|
|
||||||
"Urawa Red Diamonds": { formation:"4-2-3-1", tactic:"possession" },
|
|
||||||
"Gamba Osaka": { formation:"4-4-2", tactic:"counter" },
|
|
||||||
"Cerezo Osaka": { formation:"4-4-2", tactic:"counter" },
|
|
||||||
"Nagoya Grampus": { formation:"4-2-3-1", tactic:"low_block" },
|
|
||||||
"Sanfrecce Hiroshima": { formation:"3-5-2", tactic:"possession" },
|
|
||||||
"Consadole Sapporo": { formation:"3-5-2", tactic:"high_press" },
|
|
||||||
"Shimizu S-Pulse": { formation:"4-4-2", tactic:"counter" },
|
|
||||||
"Ventforet Kofu": { formation:"4-4-2", tactic:"counter" },
|
|
||||||
"Tokyo Verdy": { formation:"4-3-3", tactic:"possession" },
|
|
||||||
"JEF United Chiba": { formation:"4-3-3", tactic:"counter" }
|
|
||||||
};
|
|
||||||
|
|
||||||
|
if (response.status === 204) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = response.headers.get("content-type") || "";
|
||||||
|
if (contentType.includes("application/json")) {
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
return response.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openModal(modalId) {
|
||||||
|
const modal = $(modalId);
|
||||||
|
if (!modal) return;
|
||||||
|
modal.classList.add("open");
|
||||||
|
modal.setAttribute("aria-hidden", "false");
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal(modalId) {
|
||||||
|
const modal = $(modalId);
|
||||||
|
if (!modal) return;
|
||||||
|
modal.classList.remove("open");
|
||||||
|
modal.setAttribute("aria-hidden", "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getModeTeamCount(mode) {
|
||||||
|
return mode === "single" ? 2 : 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTeamSelectors() {
|
||||||
|
const wrap = $("teamSelectWrap");
|
||||||
|
const mode = $("modeSelect").value;
|
||||||
|
const required = getModeTeamCount(mode);
|
||||||
|
const autoFill = $("autoFill").checked;
|
||||||
|
|
||||||
|
$("teamCount").innerHTML = `<option value="${required}">${required}</option>`;
|
||||||
|
|
||||||
|
if (state.teams.length === 0) {
|
||||||
|
wrap.innerHTML = "<p>Loading teams...</p>";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = state.teams
|
||||||
|
.map((team) => `<option value="${team.name}">${team.display_name}</option>`)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const fields = [];
|
||||||
|
for (let i = 0; i < required; i++) {
|
||||||
|
fields.push(`
|
||||||
|
<label>
|
||||||
|
Team ${i + 1}
|
||||||
|
<select data-team-index="${i}" ${autoFill ? "disabled" : ""}>
|
||||||
|
${options}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
wrap.innerHTML = fields.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCreatePayload() {
|
||||||
|
const mode = $("modeSelect").value;
|
||||||
|
const autoFill = $("autoFill").checked;
|
||||||
|
const required = getModeTeamCount(mode);
|
||||||
|
|
||||||
|
if (autoFill) {
|
||||||
|
return { mode, auto_fill: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const picks = [...$("teamSelectWrap").querySelectorAll("select")].map((s) => s.value);
|
||||||
|
const uniqueCount = new Set(picks).size;
|
||||||
|
if (picks.length !== required || uniqueCount !== picks.length) {
|
||||||
|
throw new Error("Please select unique teams for this mode");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { mode, auto_fill: false, teams: picks };
|
||||||
|
}
|
||||||
|
|
||||||
|
function cardActions(sim) {
|
||||||
|
const common = `
|
||||||
|
<button class="btn secondary" data-action="view" data-id="${sim.id}">View</button>
|
||||||
|
<button class="btn secondary" data-action="clone" data-id="${sim.id}">Clone</button>
|
||||||
|
<button class="btn warn" data-action="delete" data-id="${sim.id}">Delete</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (sim.status === "pending") {
|
||||||
|
return `
|
||||||
|
<button class="btn" data-action="start" data-id="${sim.id}">Start</button>
|
||||||
|
${common}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sim.status === "completed") {
|
||||||
|
return `
|
||||||
|
<button class="btn secondary" data-action="export" data-id="${sim.id}">Export CSV</button>
|
||||||
|
${common}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return common;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCardElement(sim) {
|
||||||
|
const article = document.createElement("article");
|
||||||
|
article.className = "card";
|
||||||
|
article.dataset.id = String(sim.id);
|
||||||
|
article.innerHTML = `
|
||||||
|
<div class="card-top">
|
||||||
|
<div>
|
||||||
|
<h3 class="card-title" data-role="title"></h3>
|
||||||
|
<p class="card-id" data-role="idline"></p>
|
||||||
|
</div>
|
||||||
|
<span class="pill" data-role="pill"></span>
|
||||||
|
</div>
|
||||||
|
<p class="card-line"><strong>Progress:</strong> <span data-role="progress"></span></p>
|
||||||
|
<p class="card-score" data-role="scoreboard"></p>
|
||||||
|
<p class="card-line"><strong>Outcome:</strong> <span data-role="outcome"></span></p>
|
||||||
|
<div class="card-actions" data-role="actions"></div>
|
||||||
|
`;
|
||||||
|
return article;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCardElement(card, sim) {
|
||||||
|
card.querySelector('[data-role="title"]').textContent = sim.title;
|
||||||
|
card.querySelector('[data-role="idline"]').textContent = `sim-${sim.id} | ${sim.mode} | seed=${sim.seed}`;
|
||||||
|
card.querySelector('[data-role="progress"]').textContent = sim.progress;
|
||||||
|
card.querySelector('[data-role="scoreboard"]').textContent = sim.scoreboard;
|
||||||
|
card.querySelector('[data-role="outcome"]').textContent = sim.outcome;
|
||||||
|
|
||||||
|
const pill = card.querySelector('[data-role="pill"]');
|
||||||
|
pill.className = `pill ${sim.status}`;
|
||||||
|
pill.textContent = sim.status;
|
||||||
|
|
||||||
|
if (card.dataset.status !== sim.status) {
|
||||||
|
card.dataset.status = sim.status;
|
||||||
|
card.querySelector('[data-role="actions"]').innerHTML = cardActions(sim);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDashboard() {
|
||||||
|
const root = $("dashboard");
|
||||||
|
if (!root) return;
|
||||||
|
|
||||||
|
if (state.simulations.length === 0) {
|
||||||
|
root.innerHTML = `<div class="empty" id="dashboardEmpty">No simulation instances yet. Create one to start the web simulation flow.</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingCards = new Map(
|
||||||
|
[...root.querySelectorAll("article.card")].map((card) => [Number(card.dataset.id), card]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeIds = new Set(state.simulations.map((sim) => sim.id));
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
|
||||||
|
for (const sim of state.simulations) {
|
||||||
|
let card = existingCards.get(sim.id);
|
||||||
|
if (!card) {
|
||||||
|
card = createCardElement(sim);
|
||||||
|
}
|
||||||
|
updateCardElement(card, sim);
|
||||||
|
fragment.appendChild(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [id, card] of existingCards.entries()) {
|
||||||
|
if (!activeIds.has(id) && card.parentElement === root) {
|
||||||
|
card.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
root.replaceChildren(fragment);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStats() {
|
||||||
|
const total = state.simulations.length;
|
||||||
|
const running = state.simulations.filter((s) => s.status === "running").length;
|
||||||
|
const completed = state.simulations.filter((s) => s.status === "completed").length;
|
||||||
|
$("statInstances").textContent = String(total);
|
||||||
|
$("statRunning").textContent = String(running);
|
||||||
|
$("statCompleted").textContent = String(completed);
|
||||||
|
$("statTeams").textContent = String(state.teams.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshSimulations() {
|
||||||
|
state.simulations = await request("/api/simulations");
|
||||||
|
renderStats();
|
||||||
|
renderDashboard();
|
||||||
|
|
||||||
|
if (state.selectedDetailId !== null) {
|
||||||
|
const exists = state.simulations.some((s) => s.id === state.selectedDetailId);
|
||||||
|
if (!exists) {
|
||||||
|
state.selectedDetailId = null;
|
||||||
|
closeModal("detailModal");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await loadDetail(state.selectedDetailId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function textOrPlaceholder(lines, fallback) {
|
||||||
|
if (!Array.isArray(lines) || lines.length === 0) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDetail(id) {
|
||||||
|
const detail = await request(`/api/simulations/${id}`);
|
||||||
|
state.selectedDetailId = id;
|
||||||
|
|
||||||
|
$("detailTitle").textContent = `sim-${detail.id} - ${detail.title}`;
|
||||||
|
$("detailScoreboard").textContent = `${detail.scoreboard} | ${detail.outcome}`;
|
||||||
|
$("detailLogs").textContent = textOrPlaceholder(detail.logs, "No events yet.");
|
||||||
|
$("detailStats").textContent = textOrPlaceholder(detail.stats_lines, "No stats available yet.");
|
||||||
|
$("detailCompetition").textContent = textOrPlaceholder(detail.competition_lines, "No standings/bracket available yet.");
|
||||||
|
$("detailHistory").textContent = textOrPlaceholder(detail.history_lines, "No history recorded yet.");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSimulation() {
|
||||||
|
try {
|
||||||
|
const payload = getCreatePayload();
|
||||||
|
const created = await request("/api/simulations", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
closeModal("createModal");
|
||||||
|
setStatus(`Created sim-${created.id}`);
|
||||||
|
await refreshSimulations();
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(`Create failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startSimulation(id) {
|
||||||
|
try {
|
||||||
|
await request(`/api/simulations/${id}/start`, { method: "POST" });
|
||||||
|
setStatus(`Started sim-${id}`);
|
||||||
|
await refreshSimulations();
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(`Start failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cloneSimulation(id) {
|
||||||
|
try {
|
||||||
|
const created = await request(`/api/simulations/${id}/clone`, { method: "POST" });
|
||||||
|
setStatus(`Cloned sim-${id} to sim-${created.id}`);
|
||||||
|
await refreshSimulations();
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(`Clone failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSimulation(id) {
|
||||||
|
try {
|
||||||
|
await request(`/api/simulations/${id}`, { method: "DELETE" });
|
||||||
|
if (state.selectedDetailId === id) {
|
||||||
|
state.selectedDetailId = null;
|
||||||
|
closeModal("detailModal");
|
||||||
|
}
|
||||||
|
setStatus(`Deleted sim-${id}`);
|
||||||
|
await refreshSimulations();
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(`Delete failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportSimulation(id) {
|
||||||
|
window.location.href = `/api/simulations/${id}/export.csv`;
|
||||||
|
setStatus(`Exporting sim-${id} CSV...`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindEvents() {
|
||||||
|
$("openCreateBtn").addEventListener("click", () => openModal("createModal"));
|
||||||
|
$("createBtn").addEventListener("click", createSimulation);
|
||||||
|
$("modeSelect").addEventListener("change", renderTeamSelectors);
|
||||||
|
$("autoFill").addEventListener("change", renderTeamSelectors);
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-close]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
closeModal(button.dataset.close);
|
||||||
|
if (button.dataset.close === "detailModal") {
|
||||||
|
state.selectedDetailId = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
["createModal", "detailModal"].forEach((id) => {
|
||||||
|
const modal = $(id);
|
||||||
|
modal.addEventListener("click", (event) => {
|
||||||
|
if (event.target === modal) {
|
||||||
|
closeModal(id);
|
||||||
|
if (id === "detailModal") {
|
||||||
|
state.selectedDetailId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$("dashboard").addEventListener("click", async (event) => {
|
||||||
|
const button = event.target.closest("button[data-action]");
|
||||||
|
if (!button) return;
|
||||||
|
|
||||||
|
const id = Number(button.dataset.id);
|
||||||
|
const action = button.dataset.action;
|
||||||
|
|
||||||
|
if (action === "start") return startSimulation(id);
|
||||||
|
if (action === "clone") return cloneSimulation(id);
|
||||||
|
if (action === "delete") return deleteSimulation(id);
|
||||||
|
if (action === "export") return exportSimulation(id);
|
||||||
|
if (action === "view") {
|
||||||
|
try {
|
||||||
|
await loadDetail(id);
|
||||||
|
openModal("detailModal");
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(`Failed to load detail: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function boot() {
|
||||||
|
bindEvents();
|
||||||
|
try {
|
||||||
|
setStatus("Loading teams and simulations...");
|
||||||
|
state.teams = await request("/api/teams");
|
||||||
|
renderTeamSelectors();
|
||||||
|
await refreshSimulations();
|
||||||
|
setStatus("Connected to SoccerCloud backend on port 9009.");
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(`Startup failed: ${error.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.pollHandle) {
|
||||||
|
clearInterval(state.pollHandle);
|
||||||
|
}
|
||||||
|
|
||||||
|
state.pollHandle = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
await refreshSimulations();
|
||||||
|
} catch (error) {
|
||||||
|
setStatus(`Polling issue: ${error.message}`);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
boot();
|
||||||
|
|
|
||||||
1068
index.html
1068
index.html
File diff suppressed because it is too large
Load diff
15
src/main.rs
15
src/main.rs
|
|
@ -5,6 +5,7 @@ mod instance;
|
||||||
mod sim;
|
mod sim;
|
||||||
mod ui;
|
mod ui;
|
||||||
mod utils;
|
mod utils;
|
||||||
|
mod web;
|
||||||
|
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io::{self, Write};
|
use std::io::{self, Write};
|
||||||
|
|
@ -16,6 +17,7 @@ use data::{display_name, TEAMS};
|
||||||
use export::simulation_to_csv_bytes;
|
use export::simulation_to_csv_bytes;
|
||||||
use sim::{run_simulation, SimulationType};
|
use sim::{run_simulation, SimulationType};
|
||||||
use utils::{derive_seed, Rng};
|
use utils::{derive_seed, Rng};
|
||||||
|
use web::run_web_server;
|
||||||
|
|
||||||
#[derive(Debug, Parser)]
|
#[derive(Debug, Parser)]
|
||||||
#[command(name = "soccercloud")]
|
#[command(name = "soccercloud")]
|
||||||
|
|
@ -27,6 +29,9 @@ struct Cli {
|
||||||
#[arg(long, global = true, value_enum, default_value_t = Speed::X1)]
|
#[arg(long, global = true, value_enum, default_value_t = Speed::X1)]
|
||||||
speed: Speed,
|
speed: Speed,
|
||||||
|
|
||||||
|
#[arg(long, global = true)]
|
||||||
|
web: bool,
|
||||||
|
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: Option<Commands>,
|
command: Option<Commands>,
|
||||||
}
|
}
|
||||||
|
|
@ -71,6 +76,16 @@ fn main() -> io::Result<()> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
let base_seed = cli.seed.unwrap_or_else(|| Rng::from_time().next_u64());
|
let base_seed = cli.seed.unwrap_or_else(|| Rng::from_time().next_u64());
|
||||||
|
|
||||||
|
if cli.web {
|
||||||
|
if cli.command.is_some() {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidInput,
|
||||||
|
"--web cannot be combined with subcommands",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return run_web_server(base_seed, cli.speed);
|
||||||
|
}
|
||||||
|
|
||||||
match cli.command {
|
match cli.command {
|
||||||
None => {
|
None => {
|
||||||
let app = App::new(base_seed, cli.speed);
|
let app = App::new(base_seed, cli.speed);
|
||||||
|
|
|
||||||
514
src/web.rs
Normal file
514
src/web.rs
Normal file
|
|
@ -0,0 +1,514 @@
|
||||||
|
use std::collections::{HashSet, VecDeque};
|
||||||
|
use std::io;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use actix_web::http::header;
|
||||||
|
use actix_web::{web, App as ActixApp, HttpResponse, HttpServer, Responder};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::app::Speed;
|
||||||
|
use crate::data::{display_name, TEAMS};
|
||||||
|
use crate::instance::{SimStatus, SimulationInstance};
|
||||||
|
use crate::sim::SimulationType;
|
||||||
|
use crate::utils::{derive_seed, Rng};
|
||||||
|
|
||||||
|
const WEB_PORT: u16 = 9009;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct SharedState {
|
||||||
|
inner: Arc<Mutex<WebState>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WebState {
|
||||||
|
base_seed: u64,
|
||||||
|
speed: Speed,
|
||||||
|
next_id: usize,
|
||||||
|
instances: Vec<SimulationInstance>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WebState {
|
||||||
|
fn new(base_seed: u64, speed: Speed) -> Self {
|
||||||
|
Self {
|
||||||
|
base_seed,
|
||||||
|
speed,
|
||||||
|
next_id: 0,
|
||||||
|
instances: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_seed(&self) -> u64 {
|
||||||
|
derive_seed(self.base_seed, self.next_id as u64 + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tick(&mut self) {
|
||||||
|
let frames = self.speed.frames_per_tick();
|
||||||
|
for inst in &mut self.instances {
|
||||||
|
if matches!(inst.status, SimStatus::Running { .. }) {
|
||||||
|
inst.tick(frames);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn simulation_mut(&mut self, id: usize) -> Option<&mut SimulationInstance> {
|
||||||
|
self.instances.iter_mut().find(|s| s.id == id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn simulation(&self, id: usize) -> Option<&SimulationInstance> {
|
||||||
|
self.instances.iter().find(|s| s.id == id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_simulation(&mut self, id: usize) -> bool {
|
||||||
|
let before = self.instances.len();
|
||||||
|
self.instances.retain(|s| s.id != id);
|
||||||
|
self.instances.len() != before
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct ErrorDto {
|
||||||
|
error: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct TeamDto {
|
||||||
|
name: String,
|
||||||
|
display_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct SimulationSummaryDto {
|
||||||
|
id: usize,
|
||||||
|
mode: String,
|
||||||
|
status: String,
|
||||||
|
seed: u64,
|
||||||
|
teams: Vec<String>,
|
||||||
|
title: String,
|
||||||
|
progress: String,
|
||||||
|
scoreboard: String,
|
||||||
|
outcome: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct SimulationDetailDto {
|
||||||
|
id: usize,
|
||||||
|
mode: String,
|
||||||
|
status: String,
|
||||||
|
seed: u64,
|
||||||
|
teams: Vec<String>,
|
||||||
|
title: String,
|
||||||
|
progress: String,
|
||||||
|
scoreboard: String,
|
||||||
|
outcome: String,
|
||||||
|
logs: Vec<String>,
|
||||||
|
stats_lines: Vec<String>,
|
||||||
|
competition_lines: Vec<String>,
|
||||||
|
history_lines: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct CreateSimulationRequest {
|
||||||
|
mode: String,
|
||||||
|
teams: Option<Vec<String>>,
|
||||||
|
auto_fill: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct CreateSimulationResponse {
|
||||||
|
id: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sim_type_label(sim_type: SimulationType) -> &'static str {
|
||||||
|
match sim_type {
|
||||||
|
SimulationType::Single => "Single Match",
|
||||||
|
SimulationType::League4 => "4-Team League",
|
||||||
|
SimulationType::Knockout4 => "4-Team Knockout",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mode_to_sim_type(mode: &str) -> Option<SimulationType> {
|
||||||
|
match mode {
|
||||||
|
"single" => Some(SimulationType::Single),
|
||||||
|
"league4" => Some(SimulationType::League4),
|
||||||
|
"knockout4" => Some(SimulationType::Knockout4),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn required_team_count(sim_type: SimulationType) -> usize {
|
||||||
|
match sim_type {
|
||||||
|
SimulationType::Single => 2,
|
||||||
|
SimulationType::League4 | SimulationType::Knockout4 => 4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status_label(status: &SimStatus) -> &'static str {
|
||||||
|
match status {
|
||||||
|
SimStatus::Pending => "pending",
|
||||||
|
SimStatus::Running { .. } => "running",
|
||||||
|
SimStatus::Completed => "completed",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn simulation_title(sim: &SimulationInstance) -> String {
|
||||||
|
match sim.sim_type {
|
||||||
|
SimulationType::Single => {
|
||||||
|
if sim.teams.len() == 2 {
|
||||||
|
format!("Match: {} vs {}", sim.teams[0], sim.teams[1])
|
||||||
|
} else {
|
||||||
|
"Match".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => sim_type_label(sim.sim_type).to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_summary(sim: &SimulationInstance) -> SimulationSummaryDto {
|
||||||
|
SimulationSummaryDto {
|
||||||
|
id: sim.id,
|
||||||
|
mode: sim.sim_type.as_str().to_string(),
|
||||||
|
status: status_label(&sim.status).to_string(),
|
||||||
|
seed: sim.seed,
|
||||||
|
teams: sim.teams.clone(),
|
||||||
|
title: simulation_title(sim),
|
||||||
|
progress: sim.progress_text(),
|
||||||
|
scoreboard: sim.scoreboard.clone(),
|
||||||
|
outcome: sim.outcome_summary(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_detail(sim: &SimulationInstance) -> SimulationDetailDto {
|
||||||
|
SimulationDetailDto {
|
||||||
|
id: sim.id,
|
||||||
|
mode: sim.sim_type.as_str().to_string(),
|
||||||
|
status: status_label(&sim.status).to_string(),
|
||||||
|
seed: sim.seed,
|
||||||
|
teams: sim.teams.clone(),
|
||||||
|
title: simulation_title(sim),
|
||||||
|
progress: sim.progress_text(),
|
||||||
|
scoreboard: sim.scoreboard.clone(),
|
||||||
|
outcome: sim.outcome_summary(),
|
||||||
|
logs: vecdeque_to_vec(&sim.logs),
|
||||||
|
stats_lines: sim.stats_lines.clone(),
|
||||||
|
competition_lines: sim.competition_lines.clone(),
|
||||||
|
history_lines: sim.history_lines.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn vecdeque_to_vec(items: &VecDeque<String>) -> Vec<String> {
|
||||||
|
items.iter().cloned().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolve_teams(
|
||||||
|
sim_type: SimulationType,
|
||||||
|
provided_teams: Option<Vec<String>>,
|
||||||
|
auto_fill: bool,
|
||||||
|
seed: u64,
|
||||||
|
) -> Result<Vec<String>, String> {
|
||||||
|
let required = required_team_count(sim_type);
|
||||||
|
let mut selected = provided_teams.unwrap_or_default();
|
||||||
|
let mut seen = HashSet::new();
|
||||||
|
|
||||||
|
if selected.len() > required {
|
||||||
|
return Err(format!(
|
||||||
|
"mode {} accepts at most {} teams",
|
||||||
|
sim_type.as_str(),
|
||||||
|
required
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
for team in &selected {
|
||||||
|
if !TEAMS.contains(&team.as_str()) {
|
||||||
|
return Err(format!("Unknown team: {team}"));
|
||||||
|
}
|
||||||
|
if !seen.insert(team.clone()) {
|
||||||
|
return Err(format!("Duplicate team: {team}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !auto_fill && selected.len() != required {
|
||||||
|
return Err(format!(
|
||||||
|
"mode {} requires exactly {} teams when auto_fill=false",
|
||||||
|
sim_type.as_str(),
|
||||||
|
required
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if auto_fill {
|
||||||
|
let mut pool: Vec<&str> = TEAMS
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|team| !seen.contains(*team))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut rng = Rng::new(seed);
|
||||||
|
while selected.len() < required {
|
||||||
|
if pool.is_empty() {
|
||||||
|
return Err("Not enough teams available for auto-fill".to_string());
|
||||||
|
}
|
||||||
|
let i = rng.range_usize(pool.len());
|
||||||
|
selected.push(pool.remove(i).to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(selected)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn index_html() -> impl Responder {
|
||||||
|
HttpResponse::Ok()
|
||||||
|
.insert_header((header::CONTENT_TYPE, "text/html; charset=utf-8"))
|
||||||
|
.body(include_str!("../index.html"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn data_js() -> impl Responder {
|
||||||
|
HttpResponse::Ok()
|
||||||
|
.insert_header((
|
||||||
|
header::CONTENT_TYPE,
|
||||||
|
"application/javascript; charset=utf-8",
|
||||||
|
))
|
||||||
|
.body(include_str!("../data.js"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn api_teams() -> impl Responder {
|
||||||
|
let items: Vec<TeamDto> = TEAMS
|
||||||
|
.iter()
|
||||||
|
.map(|team| TeamDto {
|
||||||
|
name: (*team).to_string(),
|
||||||
|
display_name: display_name(team),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
HttpResponse::Ok().json(items)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn api_list_simulations(state: web::Data<SharedState>) -> impl Responder {
|
||||||
|
let guard = match state.inner.lock() {
|
||||||
|
Ok(g) => g,
|
||||||
|
Err(_) => {
|
||||||
|
return HttpResponse::InternalServerError().json(ErrorDto {
|
||||||
|
error: "state lock poisoned".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut sims = guard.instances.iter().map(to_summary).collect::<Vec<_>>();
|
||||||
|
sims.sort_by_key(|s| s.id);
|
||||||
|
HttpResponse::Ok().json(sims)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn api_get_simulation(
|
||||||
|
path: web::Path<usize>,
|
||||||
|
state: web::Data<SharedState>,
|
||||||
|
) -> impl Responder {
|
||||||
|
let id = path.into_inner();
|
||||||
|
let guard = match state.inner.lock() {
|
||||||
|
Ok(g) => g,
|
||||||
|
Err(_) => {
|
||||||
|
return HttpResponse::InternalServerError().json(ErrorDto {
|
||||||
|
error: "state lock poisoned".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(sim) = guard.simulation(id) {
|
||||||
|
return HttpResponse::Ok().json(to_detail(sim));
|
||||||
|
}
|
||||||
|
HttpResponse::NotFound().json(ErrorDto {
|
||||||
|
error: format!("simulation {id} not found"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn api_create_simulation(
|
||||||
|
payload: web::Json<CreateSimulationRequest>,
|
||||||
|
state: web::Data<SharedState>,
|
||||||
|
) -> impl Responder {
|
||||||
|
let mut guard = match state.inner.lock() {
|
||||||
|
Ok(g) => g,
|
||||||
|
Err(_) => {
|
||||||
|
return HttpResponse::InternalServerError().json(ErrorDto {
|
||||||
|
error: "state lock poisoned".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(sim_type) = mode_to_sim_type(payload.mode.as_str()) else {
|
||||||
|
return HttpResponse::BadRequest().json(ErrorDto {
|
||||||
|
error: format!("Unsupported mode: {}", payload.mode),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let id = guard.next_id;
|
||||||
|
let seed = guard.next_seed();
|
||||||
|
let auto_fill = payload.auto_fill.unwrap_or(true);
|
||||||
|
let teams = match resolve_teams(sim_type, payload.teams.clone(), auto_fill, seed) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => return HttpResponse::BadRequest().json(ErrorDto { error: e }),
|
||||||
|
};
|
||||||
|
|
||||||
|
let sim = SimulationInstance::new(id, sim_type, teams, seed);
|
||||||
|
guard.instances.push(sim);
|
||||||
|
guard.next_id += 1;
|
||||||
|
|
||||||
|
HttpResponse::Created().json(CreateSimulationResponse { id })
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn api_start_simulation(
|
||||||
|
path: web::Path<usize>,
|
||||||
|
state: web::Data<SharedState>,
|
||||||
|
) -> impl Responder {
|
||||||
|
let id = path.into_inner();
|
||||||
|
let mut guard = match state.inner.lock() {
|
||||||
|
Ok(g) => g,
|
||||||
|
Err(_) => {
|
||||||
|
return HttpResponse::InternalServerError().json(ErrorDto {
|
||||||
|
error: "state lock poisoned".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(sim) = guard.simulation_mut(id) {
|
||||||
|
sim.start();
|
||||||
|
return HttpResponse::Ok().json(to_summary(sim));
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpResponse::NotFound().json(ErrorDto {
|
||||||
|
error: format!("simulation {id} not found"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn api_clone_simulation(
|
||||||
|
path: web::Path<usize>,
|
||||||
|
state: web::Data<SharedState>,
|
||||||
|
) -> impl Responder {
|
||||||
|
let id = path.into_inner();
|
||||||
|
let mut guard = match state.inner.lock() {
|
||||||
|
Ok(g) => g,
|
||||||
|
Err(_) => {
|
||||||
|
return HttpResponse::InternalServerError().json(ErrorDto {
|
||||||
|
error: "state lock poisoned".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(existing) = guard.simulation(id).cloned() else {
|
||||||
|
return HttpResponse::NotFound().json(ErrorDto {
|
||||||
|
error: format!("simulation {id} not found"),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let new_id = guard.next_id;
|
||||||
|
let new_seed = guard.next_seed();
|
||||||
|
let clone = existing.clone_as(new_id, new_seed);
|
||||||
|
guard.instances.push(clone);
|
||||||
|
guard.next_id += 1;
|
||||||
|
|
||||||
|
HttpResponse::Created().json(CreateSimulationResponse { id: new_id })
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn api_delete_simulation(
|
||||||
|
path: web::Path<usize>,
|
||||||
|
state: web::Data<SharedState>,
|
||||||
|
) -> impl Responder {
|
||||||
|
let id = path.into_inner();
|
||||||
|
let mut guard = match state.inner.lock() {
|
||||||
|
Ok(g) => g,
|
||||||
|
Err(_) => {
|
||||||
|
return HttpResponse::InternalServerError().json(ErrorDto {
|
||||||
|
error: "state lock poisoned".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if guard.remove_simulation(id) {
|
||||||
|
return HttpResponse::NoContent().finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpResponse::NotFound().json(ErrorDto {
|
||||||
|
error: format!("simulation {id} not found"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn api_export_csv(path: web::Path<usize>, state: web::Data<SharedState>) -> impl Responder {
|
||||||
|
let id = path.into_inner();
|
||||||
|
let guard = match state.inner.lock() {
|
||||||
|
Ok(g) => g,
|
||||||
|
Err(_) => {
|
||||||
|
return HttpResponse::InternalServerError().json(ErrorDto {
|
||||||
|
error: "state lock poisoned".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(sim) = guard.simulation(id) else {
|
||||||
|
return HttpResponse::NotFound().json(ErrorDto {
|
||||||
|
error: format!("simulation {id} not found"),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let csv = match sim.export_csv() {
|
||||||
|
Ok(bytes) => bytes,
|
||||||
|
Err(e) => return HttpResponse::BadRequest().json(ErrorDto { error: e }),
|
||||||
|
};
|
||||||
|
|
||||||
|
let filename = format!("sim-{}-{}.csv", sim.id, sim.sim_type.as_str());
|
||||||
|
HttpResponse::Ok()
|
||||||
|
.insert_header((header::CONTENT_TYPE, "text/csv; charset=utf-8"))
|
||||||
|
.insert_header((
|
||||||
|
header::CONTENT_DISPOSITION,
|
||||||
|
format!("attachment; filename=\"{}\"", filename),
|
||||||
|
))
|
||||||
|
.body(csv)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run_web_server(base_seed: u64, speed: Speed) -> io::Result<()> {
|
||||||
|
let shared = SharedState {
|
||||||
|
inner: Arc::new(Mutex::new(WebState::new(base_seed, speed))),
|
||||||
|
};
|
||||||
|
|
||||||
|
let ticker = shared.clone();
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"Starting SoccerCloud web UI at http://127.0.0.1:{WEB_PORT} (seed={base_seed}, speed={})",
|
||||||
|
speed.label()
|
||||||
|
);
|
||||||
|
|
||||||
|
actix_web::rt::System::new().block_on(async move {
|
||||||
|
actix_web::rt::spawn(async move {
|
||||||
|
let mut interval = actix_web::rt::time::interval(Duration::from_millis(60));
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
if let Ok(mut guard) = ticker.inner.lock() {
|
||||||
|
guard.tick();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
HttpServer::new(move || {
|
||||||
|
ActixApp::new()
|
||||||
|
.app_data(web::Data::new(shared.clone()))
|
||||||
|
.route("/", web::get().to(index_html))
|
||||||
|
.route("/index.html", web::get().to(index_html))
|
||||||
|
.route("/data.js", web::get().to(data_js))
|
||||||
|
.service(
|
||||||
|
web::scope("/api")
|
||||||
|
.route("/teams", web::get().to(api_teams))
|
||||||
|
.route("/simulations", web::get().to(api_list_simulations))
|
||||||
|
.route("/simulations", web::post().to(api_create_simulation))
|
||||||
|
.route("/simulations/{id}", web::get().to(api_get_simulation))
|
||||||
|
.route("/simulations/{id}", web::delete().to(api_delete_simulation))
|
||||||
|
.route(
|
||||||
|
"/simulations/{id}/start",
|
||||||
|
web::post().to(api_start_simulation),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/simulations/{id}/clone",
|
||||||
|
web::post().to(api_clone_simulation),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/simulations/{id}/export.csv",
|
||||||
|
web::get().to(api_export_csv),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.bind(("127.0.0.1", WEB_PORT))?
|
||||||
|
.run()
|
||||||
|
.await
|
||||||
|
})
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue