Compare commits

..

No commits in common. "fc99f1a6223649d8cd20fe31cb1c54c733e8be2d" and "dcdd06c8c3f9a76e7d6afdc823799462c462dceb" have entirely different histories.

12 changed files with 1002 additions and 3789 deletions

1357
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -7,8 +7,6 @@ edition = "2021"
ratatui = "0.29"
crossterm = "0.28"
clap = { version = "4.5", features = ["derive"] }
actix-web = "4.11"
serde = { version = "1.0", features = ["derive"] }
[profile.release]
opt-level = 3

View file

@ -78,31 +78,3 @@
- Added modal scrolling controls (`j/k` or up/down) and close controls (`Esc` or `q`).
- Simplified detail view to focus on scoreboard, logs, and instance info.
- Added detail-panel hint bar to direct users to the new dedicated modals.
## 2026-02-10 - National team expansion
### Scope completed
- Expanded team database to include 50+ national teams in addition to existing clubs.
- Added national-team flag mappings, including `PRC China`.
- Added tactic/formation profile mappings for the new 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.
## 2026-02-11 - Open listen option for web mode
### Scope completed
- Added `--listen-open` CLI argument for web mode.
- Enforced `--listen-open` usage only when `--web` is present.
- Updated web server bind address behavior:
- default: `127.0.0.1:9009`
- open listen: `0.0.0.0:9009`
- Updated startup logs and README usage examples for LAN-accessible mode.

262
README.md
View file

@ -1,198 +1,126 @@
# SoccerCloud CLI (Rust)
# ⚽ SoccerCloud — Cloudified Soccer Simulation Environment
<p align="center">
<img src="sc-logo.jpg" alt="SoccerCloud Logo" width="200">
</p>
**Live Demo:** [https://mentalnet.xyz/soccercloud/](https://mentalnet.xyz/soccercloud/)
**Author:** [markmental / MentalNet.xyz](https://mentalnet.xyz)
**License:** MIT
Terminal-native rebuild of MentalNet SoccerCloud with a cloud-dashboard feel.
---
## Overview
## 🧠 Overview
This project is a Rust TUI/CLI soccer simulator with:
**SoccerCloud** is a browser-based soccer simulator that reimagines match simulations through the aesthetic and structure of a **cloud orchestration dashboard** — think *OpenStack meets Football Manager*.
- Single Match, 4-Team League, and 4-Team Knockout modes
- Live match logs, scoreboard, and instance lifecycle controls
- Seeded deterministic runs (`--seed`) for reproducible results
- CSV export for single, league, and knockout outputs
- Expanded team pool (clubs + 50+ national teams, including `PRC China`)
Each match, league, or knockout bracket behaves like a **“virtual instance”**, complete with lifecycle controls:
- **Create / Start / View / Delete / Clone / Export**
- Real-time logs, xG data, formations, and tactical analytics
- **Dynamic UI** styled like a cloud console with per-match telemetry
## Requirements
SoccerCloud is written entirely in **HTML, CSS, and vanilla JavaScript**, with no external backend dependencies. It runs fully client-side and is suitable for static hosting.
- Rust toolchain (stable)
- Cargo
- A terminal that supports UTF-8 and colors
---
Install Rust (if needed):
## 🌐 Live Deployment
> [https://mentalnet.xyz/soccercloud/](https://mentalnet.xyz/soccercloud/)
Hosted on **MentalNet.xyz**, the current deployment showcases all features including:
- Match instance dashboard
- 4-team League and Knockout modes
- CSV export of results and tables
- Auto-team picker with J-League and European clubs
- Cloud-inspired modal configuration UI
---
## 🏗️ Features
| Category | Description |
|-----------|-------------|
| **Simulation Types** | Single Match, 4-Team League, 4-Team Knockout |
| **Team Database** | Includes J-League + top European clubs with realistic formations/tactics |
| **UI Design** | Styled like a lightweight OpenStack/Proxmox console |
| **Export Options** | Download match or league data as CSV |
| **Logging & Recaps** | Live xG updates, goal commentary, and tactical analysis |
| **Client-Only** | Runs directly in browser — no backend needed |
---
## 🗂️ Project Structure
```
soccercloud/
├── index.html # Main web dashboard and simulation logic
├── data.js # Team definitions, flags, formations, and tactics
└── assets/ # (Optional) icons, logos, or future expansion files
````
---
## 🚀 Getting Started (Local)
You can run SoccerCloud locally with **no build process** — just open it in a browser.
### Option 1: Double-click
```bash
open index.html
````
### Option 2: Local dev server
```bash
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
python3 -m http.server 8080
```
## Setup
Then visit:
👉 `http://localhost:8080`
Clone and build:
---
```bash
git clone https://mentalnet.xyz/forgejo/markmental/soccercloud-rust.git
cd soccercloud-cli
cargo check
```
## 🧩 Technical Notes
Run in debug mode:
* Written in **vanilla JavaScript** for speed and transparency.
* Each simulation instance is handled via a `SimulationInstance` class.
* Data persistence is session-based; future versions may support saving instance states.
* CSS uses retro **UnifrakturCook + Press Start 2P** fonts for a distinct MentalNet look.
```bash
cargo run
```
---
Build optimized binary:
## 🖥️ Upcoming: CLI Edition
```bash
cargo build --release
./target/release/soccercloud
```
> ⚡ **tuxsoccercloud** *(Coming soon!)*
## CLI Usage
A simplified **terminal version** of the simulator is in development — ideal for users who prefer a command-line workflow or want to integrate match simulations into scripts or data pipelines.
Default (interactive TUI):
Planned features:
```bash
soccercloud
```
* Text-only match recaps and league tables
* Randomized or argument-based team selection
* Fully offline operation
or with Cargo:
---
```bash
cargo run --
```
## 🤝 Contributing
Use a global seed for reproducibility:
Pull requests are welcome (when I get signups up)!
To contribute:
```bash
cargo run -- --seed 42
```
1. Fork this repository
2. Make your edits in a feature branch
3. Submit a pull request with a clear description
### Web mode (Actix)
---
Launch the web UI on port `9009`:
## 💡 Credits
```bash
cargo run -- --web
```
* Built and designed by **markmental**
* Hosted under **MentalNet.xyz**
* Inspired by *OpenStack Horizon* dashboards and *Football Manager*-style simulations
* Font assets via [Google Fonts](https://fonts.google.com)
* Icons via [Font Awesome](https://fontawesome.com)
Then open:
---
```text
http://127.0.0.1:9009
```
### ⚽ *"Deploy your next match like a VM — welcome to SoccerCloud."*
Open web mode on all network interfaces (`0.0.0.0:9009`) so other machines can access it:
```bash
cargo run -- --web --listen-open
```
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).
- `--listen-open` is only valid with `--web` and should be used on trusted networks.
### Quick match (headless)
```bash
cargo run -- quick --home "Arsenal" --away "Real Madrid" --seed 42
```
CPU auto-fill for missing team(s):
```bash
cargo run -- quick --home "England" --seed 42
cargo run -- quick --seed 42
```
### List teams
```bash
cargo run -- list
```
### Export CSV
Single:
```bash
cargo run -- export --mode single --team "Arsenal" --team "PRC China" --out match.csv --seed 42
```
League:
```bash
cargo run -- export --mode league4 --team "England" --team "Brazil" --team "Japan" --team "Germany" --out league.csv --seed 42
```
Knockout:
```bash
cargo run -- export --mode knockout4 --team "France" --team "Argentina" --team "Morocco" --team "PRC China" --out knockout.csv --seed 42
```
## TUI Controls
Global:
- `n` create Single instance
- `l` create League4 instance
- `o` create Knockout4 instance
- `s` start selected instance
- `c` clone selected instance
- `d` delete selected instance
- `e` export selected instance CSV
- `v` or `Enter` toggle dashboard/detail
- `j/k` or `Up/Down` navigate instances
- `1/2/4/0` speed control (1x/2x/4x/instant)
- `q` quit
Create modal:
- `m` set selected slot to manual team
- `p` set selected slot to CPU auto-fill
- `[` / `]` or `Left/Right` cycle manual team
- `Enter` create
- `Esc` cancel
Readable fullscreen data panels:
- `t` stats modal
- `g` standings/bracket modal
- `h` history modal
- `j/k` or `Up/Down` scroll inside modal
- `Esc` or `q` close modal
## Project Structure
```text
src/
├── main.rs # CLI entrypoint and commands
├── web.rs # Actix web server + JSON APIs
├── app.rs # App state and event loop
├── data.rs # Teams, flags, tactics, profiles
├── sim.rs # Match/league/knockout simulation engine
├── instance.rs # Simulation instance lifecycle and state
├── export.rs # CSV export
├── utils.rs # RNG + helper utilities
└── ui/
├── mod.rs
├── dashboard.rs
├── detail.rs
├── modal.rs
└── widgets.rs
```
## Notes
- Dependency policy is intentionally strict (minimal crates).
- Team data is embedded in the binary (no external runtime data files).
- Use `--seed` for deterministic comparisons and debugging.
## License
MIT

514
data.js
View file

@ -1,444 +1,80 @@
const state = {
teams: [],
simulations: [],
selectedDetailId: null,
pollHandle: null,
// =======================================================
// SoccerCloud Simulator Data File
// Contains all team, flag, and profile information.
// =======================================================
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 $ = (id) => document.getElementById(id);
const THEME_STORAGE_KEY = "soccercloud.web.theme";
const THEMES = {
"default-light": "Default (Light)",
dark: "Dark",
matcha: "Matcha",
"black-mesa": "Black Mesa",
const FORMATIONS = ["4-4-2","4-3-3","4-2-3-1","3-5-2","5-4-1"];
const TACTICS = {
counter: { label:"Counter", attackBias:1.10, goalMult:1.08, fastBreak:0.25, foulMult:1.00, blockMult:1.00, pressMult:0.95 },
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 },
};
function isThemeAllowed(theme) {
return Object.prototype.hasOwnProperty.call(THEMES, theme);
}
const TEAM_PROFILES = {
// Europe
"Arsenal": { formation:"4-3-3", tactic:"possession" },
"FC Barcelona": { formation:"4-3-3", tactic:"possession" },
"Real Madrid": { formation:"4-3-3", tactic:"counter" },
"Manchester City": { formation:"4-3-3", tactic:"possession" },
"Manchester United": { formation:"4-2-3-1", tactic:"high_press" },
"Liverpool": { formation:"4-3-3", tactic:"high_press" },
"Bayern Munich": { formation:"4-2-3-1", tactic:"high_press" },
"Borussia Dortmund": { formation:"4-2-3-1", tactic:"high_press" },
"Paris Saint-Germain": { formation:"4-3-3", tactic:"possession" },
"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" }
};
function getSavedTheme() {
try {
const saved = localStorage.getItem(THEME_STORAGE_KEY);
if (saved && isThemeAllowed(saved)) {
return saved;
}
} catch (_) {}
return "default-light";
}
function applyTheme(theme) {
const normalized = isThemeAllowed(theme) ? theme : "default-light";
document.documentElement.dataset.theme = normalized;
const select = $("themeSelect");
if (select && select.value !== normalized) {
select.value = normalized;
}
try {
localStorage.setItem(THEME_STORAGE_KEY, normalized);
} catch (_) {}
}
function initThemeControls() {
const select = $("themeSelect");
if (!select) return;
const initial = getSavedTheme();
applyTheme(initial);
select.addEventListener("change", () => {
applyTheme(select.value);
setStatus(`Theme: ${THEMES[select.value] || "Default (Light)"}`);
});
}
function setStatus(message) {
$("statusText").textContent = message;
}
async function request(path, options = {}) {
const response = await fetch(path, options);
if (!response.ok) {
let msg = `${response.status} ${response.statusText}`;
try {
const body = await response.json();
if (body && body.error) {
msg = body.error;
}
} catch (_) {}
throw new Error(msg);
}
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 setTextIfChanged(element, nextValue) {
if (!element) return;
if (element.textContent !== nextValue) {
element.textContent = nextValue;
}
}
function updateCardElement(card, sim) {
setTextIfChanged(card.querySelector('[data-role="title"]'), sim.title);
setTextIfChanged(card.querySelector('[data-role="idline"]'), `sim-${sim.id} | ${sim.mode} | seed=${sim.seed}`);
setTextIfChanged(card.querySelector('[data-role="progress"]'), sim.progress);
setTextIfChanged(card.querySelector('[data-role="scoreboard"]'), sim.scoreboard);
setTextIfChanged(card.querySelector('[data-role="outcome"]'), sim.outcome);
const pill = card.querySelector('[data-role="pill"]');
const nextPillClass = `pill ${sim.status}`;
if (pill.className !== nextPillClass) {
pill.className = nextPillClass;
}
setTextIfChanged(pill, 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) {
for (const card of root.querySelectorAll("article.card")) {
card.remove();
}
if (!$("dashboardEmpty")) {
root.innerHTML = `<div class="empty" id="dashboardEmpty">No simulation instances yet. Create one to start the web simulation flow.</div>`;
}
return;
}
const emptyNode = $("dashboardEmpty");
if (emptyNode) {
emptyNode.remove();
}
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));
for (let index = 0; index < state.simulations.length; index += 1) {
const sim = state.simulations[index];
let card = existingCards.get(sim.id);
if (!card) {
card = createCardElement(sim);
root.appendChild(card);
}
updateCardElement(card, sim);
if (root.children[index] !== card) {
root.insertBefore(card, root.children[index] || null);
}
}
for (const [id, card] of existingCards.entries()) {
if (!activeIds.has(id)) {
card.remove();
}
}
}
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() {
initThemeControls();
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();

1286
index.html

File diff suppressed because it is too large Load diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

View file

@ -10,6 +10,12 @@ pub struct Tactic {
pub press_mult: f64,
}
#[derive(Debug, Clone, Copy)]
pub struct TeamProfile {
pub formation: &'static str,
pub tactic: &'static str,
}
pub const TACTICS: [Tactic; 4] = [
Tactic {
key: "counter",
@ -53,566 +59,68 @@ pub const TACTICS: [Tactic; 4] = [
},
];
#[derive(Debug, Clone, Copy)]
pub struct Team {
pub name: &'static str,
pub flag: &'static str,
pub formation: &'static str,
pub tactic: &'static str,
}
pub const TEAMS_DATA: [Team; 85] = [
// J-League Clubs
Team {
name: "Kashima Antlers",
flag: "🇯🇵",
formation: "4-4-2",
tactic: "counter",
},
Team {
name: "Urawa Red Diamonds",
flag: "🇯🇵",
formation: "4-2-3-1",
tactic: "possession",
},
Team {
name: "Gamba Osaka",
flag: "🇯🇵",
formation: "4-4-2",
tactic: "counter",
},
Team {
name: "Cerezo Osaka",
flag: "🇯🇵",
formation: "4-4-2",
tactic: "counter",
},
Team {
name: "Kawasaki Frontale",
flag: "🇯🇵",
formation: "4-3-3",
tactic: "possession",
},
Team {
name: "Yokohama F. Marinos",
flag: "🇯🇵",
formation: "4-3-3",
tactic: "high_press",
},
Team {
name: "Nagoya Grampus",
flag: "🇯🇵",
formation: "4-2-3-1",
tactic: "low_block",
},
Team {
name: "Shimizu S-Pulse",
flag: "🇯🇵",
formation: "4-4-2",
tactic: "counter",
},
Team {
name: "Sanfrecce Hiroshima",
flag: "🇯🇵",
formation: "3-5-2",
tactic: "possession",
},
Team {
name: "Consadole Sapporo",
flag: "🇯🇵",
formation: "3-5-2",
tactic: "high_press",
},
Team {
name: "Ventforet Kofu",
flag: "🇯🇵",
formation: "4-4-2",
tactic: "counter",
},
Team {
name: "Tokyo Verdy",
flag: "🇯🇵",
formation: "4-3-3",
tactic: "possession",
},
Team {
name: "JEF United Chiba",
flag: "🇯🇵",
formation: "4-3-3",
tactic: "counter",
},
// European Clubs
Team {
name: "Arsenal",
flag: "🇬🇧",
formation: "4-3-3",
tactic: "possession",
},
Team {
name: "FC Barcelona",
flag: "🇪🇸",
formation: "4-3-3",
tactic: "possession",
},
Team {
name: "Real Madrid",
flag: "🇪🇸",
formation: "4-3-3",
tactic: "counter",
},
Team {
name: "Manchester City",
flag: "🇬🇧",
formation: "4-3-3",
tactic: "possession",
},
Team {
name: "Manchester United",
flag: "🇬🇧",
formation: "4-2-3-1",
tactic: "high_press",
},
Team {
name: "Liverpool",
flag: "🇬🇧",
formation: "4-3-3",
tactic: "high_press",
},
Team {
name: "Bayern Munich",
flag: "🇩🇪",
formation: "4-2-3-1",
tactic: "high_press",
},
Team {
name: "Borussia Dortmund",
flag: "🇩🇪",
formation: "4-2-3-1",
tactic: "high_press",
},
Team {
name: "Paris Saint-Germain",
flag: "🇫🇷",
formation: "4-3-3",
tactic: "possession",
},
Team {
name: "Juventus",
flag: "🇮🇹",
formation: "3-5-2",
tactic: "low_block",
},
Team {
name: "Inter",
flag: "🇮🇹",
formation: "3-5-2",
tactic: "low_block",
},
Team {
name: "AC Milan",
flag: "🇮🇹",
formation: "4-2-3-1",
tactic: "possession",
},
Team {
name: "Ajax",
flag: "🇳🇱",
formation: "4-3-3",
tactic: "possession",
},
Team {
name: "Benfica",
flag: "🇵🇹",
formation: "4-2-3-1",
tactic: "possession",
},
Team {
name: "Porto",
flag: "🇵🇹",
formation: "4-4-2",
tactic: "counter",
},
Team {
name: "Celtic",
flag: "🇬🇧",
formation: "4-3-3",
tactic: "possession",
},
// UEFA National Teams
Team {
name: "England",
flag: "🇬🇧",
formation: "4-2-3-1",
tactic: "high_press",
},
Team {
name: "France",
flag: "🇫🇷",
formation: "4-2-3-1",
tactic: "high_press",
},
Team {
name: "Spain",
flag: "🇪🇸",
formation: "4-3-3",
tactic: "possession",
},
Team {
name: "Germany",
flag: "🇩🇪",
formation: "4-2-3-1",
tactic: "high_press",
},
Team {
name: "Italy",
flag: "🇮🇹",
formation: "4-4-2",
tactic: "counter",
},
Team {
name: "Portugal",
flag: "🇵🇹",
formation: "4-3-3",
tactic: "possession",
},
Team {
name: "Netherlands",
flag: "🇳🇱",
formation: "4-3-3",
tactic: "possession",
},
Team {
name: "Belgium",
flag: "🇧🇪",
formation: "4-2-3-1",
tactic: "high_press",
},
Team {
name: "Croatia",
flag: "🇭🇷",
formation: "4-4-2",
tactic: "counter",
},
Team {
name: "Denmark",
flag: "🇩🇰",
formation: "4-4-2",
tactic: "counter",
},
Team {
name: "Switzerland",
flag: "🇨🇭",
formation: "4-4-2",
tactic: "counter",
},
Team {
name: "Austria",
flag: "🇦🇹",
formation: "4-2-3-1",
tactic: "counter",
},
Team {
name: "Sweden",
flag: "🇸🇪",
formation: "4-2-3-1",
tactic: "counter",
},
Team {
name: "Norway",
flag: "🇳🇴",
formation: "4-2-3-1",
tactic: "counter",
},
Team {
name: "Poland",
flag: "🇵🇱",
formation: "4-2-3-1",
tactic: "counter",
},
Team {
name: "Serbia",
flag: "🇷🇸",
formation: "4-2-3-1",
tactic: "counter",
},
Team {
name: "Turkey",
flag: "🇹🇷",
formation: "4-2-3-1",
tactic: "counter",
},
Team {
name: "Ukraine",
flag: "🇺🇦",
formation: "4-2-3-1",
tactic: "counter",
},
Team {
name: "Czech Republic",
flag: "🇨🇿",
formation: "4-2-3-1",
tactic: "counter",
},
Team {
name: "Scotland",
flag: "🇬🇧",
formation: "4-2-3-1",
tactic: "counter",
},
// CONMEBOL National Teams
Team {
name: "Argentina",
flag: "🇦🇷",
formation: "4-2-3-1",
tactic: "high_press",
},
Team {
name: "Brazil",
flag: "🇧🇷",
formation: "4-2-3-1",
tactic: "high_press",
},
Team {
name: "Uruguay",
flag: "🇺🇾",
formation: "4-4-2",
tactic: "counter",
},
Team {
name: "Colombia",
flag: "🇨🇴",
formation: "4-2-3-1",
tactic: "counter",
},
Team {
name: "Chile",
flag: "🇨🇱",
formation: "4-2-3-1",
tactic: "counter",
},
Team {
name: "Peru",
flag: "🇵🇪",
formation: "4-2-3-1",
tactic: "counter",
},
Team {
name: "Ecuador",
flag: "🇪🇨",
formation: "4-2-3-1",
tactic: "counter",
},
Team {
name: "Paraguay",
flag: "🇵🇾",
formation: "4-2-3-1",
tactic: "counter",
},
Team {
name: "Bolivia",
flag: "🇧🇴",
formation: "4-2-3-1",
tactic: "counter",
},
Team {
name: "Venezuela",
flag: "🇻🇪",
formation: "4-2-3-1",
tactic: "counter",
},
// CONCACAF National Teams
Team {
name: "United States",
flag: "🇺🇸",
formation: "4-2-3-1",
tactic: "high_press",
},
Team {
name: "Mexico",
flag: "🇲🇽",
formation: "4-4-2",
tactic: "counter",
},
Team {
name: "Canada",
flag: "🇨🇦",
formation: "4-4-2",
tactic: "counter",
},
Team {
name: "Costa Rica",
flag: "🇨🇷",
formation: "4-2-3-1",
tactic: "counter",
},
Team {
name: "Panama",
flag: "🇵🇦",
formation: "4-2-3-1",
tactic: "counter",
},
Team {
name: "Jamaica",
flag: "🇯🇲",
formation: "4-2-3-1",
tactic: "counter",
},
Team {
name: "Honduras",
flag: "🇭🇳",
formation: "4-2-3-1",
tactic: "counter",
},
// AFC/OFC National Teams
Team {
name: "Japan",
flag: "🇯🇵",
formation: "4-3-3",
tactic: "possession",
},
Team {
name: "South Korea",
flag: "🇰🇷",
formation: "4-2-3-1",
tactic: "high_press",
},
Team {
name: "Australia",
flag: "🇦🇺",
formation: "4-2-3-1",
tactic: "counter",
},
Team {
name: "Iran",
flag: "🇮🇷",
formation: "4-4-2",
tactic: "counter",
},
Team {
name: "Saudi Arabia",
flag: "🇸🇦",
formation: "4-4-2",
tactic: "counter",
},
Team {
name: "Qatar",
flag: "🇶🇦",
formation: "4-2-3-1",
tactic: "counter",
},
Team {
name: "Iraq",
flag: "🇮🇶",
formation: "4-2-3-1",
tactic: "counter",
},
Team {
name: "United Arab Emirates",
flag: "🇦🇪",
formation: "4-2-3-1",
tactic: "counter",
},
Team {
name: "PRC China",
flag: "🇨🇳",
formation: "4-3-3",
tactic: "possession",
},
// CAF National Teams
Team {
name: "Morocco",
flag: "🇲🇦",
formation: "4-2-3-1",
tactic: "high_press",
},
Team {
name: "Senegal",
flag: "🇸🇳",
formation: "4-4-2",
tactic: "counter",
},
Team {
name: "Nigeria",
flag: "🇳🇬",
formation: "4-2-3-1",
tactic: "high_press",
},
Team {
name: "Egypt",
flag: "🇪🇬",
formation: "4-2-3-1",
tactic: "counter",
},
Team {
name: "Algeria",
flag: "🇩🇿",
formation: "4-4-2",
tactic: "counter",
},
Team {
name: "Tunisia",
flag: "🇹🇳",
formation: "4-4-2",
tactic: "counter",
},
Team {
name: "Ghana",
flag: "🇬🇭",
formation: "4-2-3-1",
tactic: "counter",
},
Team {
name: "Cameroon",
flag: "🇨🇲",
formation: "4-2-3-1",
tactic: "counter",
},
Team {
name: "Ivory Coast",
flag: "🇨🇮",
formation: "4-2-3-1",
tactic: "counter",
},
Team {
name: "South Africa",
flag: "🇿🇦",
formation: "4-2-3-1",
tactic: "counter",
},
pub const TEAMS: [&str; 29] = [
"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",
"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",
];
/// Generate team names array dynamically from TEAMS_DATA at compile time
const fn extract_team_names<const N: usize>(data: &[Team; N]) -> [&str; N] {
let mut result = [""; N];
let mut i = 0;
while i < N {
result[i] = data[i].name;
i += 1;
}
result
}
/// Team names array automatically derived from TEAMS_DATA
pub const TEAMS: [&str; TEAMS_DATA.len()] = extract_team_names(&TEAMS_DATA);
pub fn team_by_name(name: &str) -> Option<&'static Team> {
TEAMS_DATA.iter().find(|t| t.name == name)
}
pub fn team_flag(team: &str) -> &'static str {
team_by_name(team).map(|t| t.flag).unwrap_or("🏳️")
match team {
"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" => "🇯🇵",
"Arsenal" | "Manchester City" | "Manchester United" | "Liverpool" | "Celtic" => "🇬🇧",
"FC Barcelona" | "Real Madrid" => "🇪🇸",
"Bayern Munich" | "Borussia Dortmund" => "🇩🇪",
"Paris Saint-Germain" => "🇫🇷",
"Juventus" | "Inter" | "AC Milan" => "🇮🇹",
"Ajax" => "🇳🇱",
"Benfica" | "Porto" => "🇵🇹",
_ => "🏳️",
}
}
pub fn display_name(team: &str) -> String {
format!("{} {}", team_flag(team), team)
}
#[derive(Debug, Clone, Copy)]
pub struct TeamProfile {
pub formation: &'static str,
pub tactic: &'static str,
}
pub fn tactic_by_key(key: &str) -> Tactic {
TACTICS
.iter()
@ -622,13 +130,126 @@ pub fn tactic_by_key(key: &str) -> Tactic {
}
pub fn profile_for(team: &str) -> TeamProfile {
team_by_name(team)
.map(|t| TeamProfile {
formation: t.formation,
tactic: t.tactic,
})
.unwrap_or(TeamProfile {
match team {
"Arsenal" => TeamProfile {
formation: "4-3-3",
tactic: "possession",
},
"FC Barcelona" => TeamProfile {
formation: "4-3-3",
tactic: "possession",
},
"Real Madrid" => TeamProfile {
formation: "4-3-3",
tactic: "counter",
},
"Manchester City" => TeamProfile {
formation: "4-3-3",
tactic: "possession",
},
"Manchester United" => TeamProfile {
formation: "4-2-3-1",
tactic: "high_press",
},
"Liverpool" => TeamProfile {
formation: "4-3-3",
tactic: "high_press",
},
"Bayern Munich" => TeamProfile {
formation: "4-2-3-1",
tactic: "high_press",
},
"Borussia Dortmund" => TeamProfile {
formation: "4-2-3-1",
tactic: "high_press",
},
"Paris Saint-Germain" => TeamProfile {
formation: "4-3-3",
tactic: "possession",
},
"Juventus" => TeamProfile {
formation: "3-5-2",
tactic: "low_block",
},
"Inter" => TeamProfile {
formation: "3-5-2",
tactic: "low_block",
},
"AC Milan" => TeamProfile {
formation: "4-2-3-1",
tactic: "possession",
},
"Ajax" => TeamProfile {
formation: "4-3-3",
tactic: "possession",
},
"Benfica" => TeamProfile {
formation: "4-2-3-1",
tactic: "possession",
},
"Porto" => TeamProfile {
formation: "4-4-2",
tactic: "counter",
})
},
"Celtic" => TeamProfile {
formation: "4-3-3",
tactic: "possession",
},
"Kawasaki Frontale" => TeamProfile {
formation: "4-3-3",
tactic: "possession",
},
"Yokohama F. Marinos" => TeamProfile {
formation: "4-3-3",
tactic: "high_press",
},
"Kashima Antlers" => TeamProfile {
formation: "4-4-2",
tactic: "counter",
},
"Urawa Red Diamonds" => TeamProfile {
formation: "4-2-3-1",
tactic: "possession",
},
"Gamba Osaka" => TeamProfile {
formation: "4-4-2",
tactic: "counter",
},
"Cerezo Osaka" => TeamProfile {
formation: "4-4-2",
tactic: "counter",
},
"Nagoya Grampus" => TeamProfile {
formation: "4-2-3-1",
tactic: "low_block",
},
"Sanfrecce Hiroshima" => TeamProfile {
formation: "3-5-2",
tactic: "possession",
},
"Consadole Sapporo" => TeamProfile {
formation: "3-5-2",
tactic: "high_press",
},
"Shimizu S-Pulse" => TeamProfile {
formation: "4-4-2",
tactic: "counter",
},
"Ventforet Kofu" => TeamProfile {
formation: "4-4-2",
tactic: "counter",
},
"Tokyo Verdy" => TeamProfile {
formation: "4-3-3",
tactic: "possession",
},
"JEF United Chiba" => TeamProfile {
formation: "4-3-3",
tactic: "counter",
},
_ => TeamProfile {
formation: "4-4-2",
tactic: "counter",
},
}
}

View file

@ -5,7 +5,6 @@ mod instance;
mod sim;
mod ui;
mod utils;
mod web;
use std::fs::File;
use std::io::{self, Write};
@ -17,7 +16,6 @@ use data::{display_name, TEAMS};
use export::simulation_to_csv_bytes;
use sim::{run_simulation, SimulationType};
use utils::{derive_seed, Rng};
use web::run_web_server;
#[derive(Debug, Parser)]
#[command(name = "soccercloud")]
@ -29,12 +27,6 @@ struct Cli {
#[arg(long, global = true, value_enum, default_value_t = Speed::X1)]
speed: Speed,
#[arg(long, global = true)]
web: bool,
#[arg(long, global = true)]
listen_open: bool,
#[command(subcommand)]
command: Option<Commands>,
}
@ -79,23 +71,6 @@ fn main() -> io::Result<()> {
let cli = Cli::parse();
let base_seed = cli.seed.unwrap_or_else(|| Rng::from_time().next_u64());
if cli.listen_open && !cli.web {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"--listen-open can only be used with --web",
));
}
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, cli.listen_open);
}
match cli.command {
None => {
let app = App::new(base_seed, cli.speed);

View file

@ -3,6 +3,7 @@ use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph};
use crate::app::App;
use crate::instance::SimStatus;
pub fn render(f: &mut Frame<'_>, area: Rect, app: &App) {
let Some(inst) = app.selected_instance() else {
@ -14,7 +15,11 @@ pub fn render(f: &mut Frame<'_>, area: Rect, app: &App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(8)])
.constraints([
Constraint::Length(3),
Constraint::Min(8),
Constraint::Length(2),
])
.split(area);
let score = Paragraph::new(inst.scoreboard.clone())
@ -44,6 +49,12 @@ pub fn render(f: &mut Frame<'_>, area: Rect, app: &App) {
f.render_widget(log_widget, middle[0]);
let mut right_lines: Vec<ListItem> = Vec::new();
let status = match &inst.status {
SimStatus::Pending => "pending",
SimStatus::Running { .. } => "running",
SimStatus::Completed => "completed",
};
right_lines.push(ListItem::new(format!("Status: {}", status)));
right_lines.push(ListItem::new(format!("Seed: {}", inst.seed)));
right_lines.push(ListItem::new(format!("Mode: {}", inst.sim_type.as_str())));
right_lines.push(ListItem::new(""));
@ -57,4 +68,8 @@ pub fn render(f: &mut Frame<'_>, area: Rect, app: &App) {
.borders(Borders::ALL),
);
f.render_widget(side, middle[1]);
let help = Paragraph::new("Open readable panels: t=Stats, g=Standings/Bracket, h=History")
.block(Block::default().borders(Borders::ALL).title("Panels"));
f.render_widget(help, chunks[2]);
}

View file

@ -12,7 +12,11 @@ use crate::app::App;
pub fn draw(f: &mut Frame<'_>, app: &App) {
let areas = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(10)])
.constraints([
Constraint::Length(3),
Constraint::Min(10),
Constraint::Length(2),
])
.split(f.area());
let header = Paragraph::new("MentalNet SoccerCloud | n/l/o create | s start | c clone | d delete | e export | v detail | t stats | g standings | h history | q quit")
@ -26,6 +30,15 @@ pub fn draw(f: &mut Frame<'_>, app: &App) {
dashboard::render(f, areas[1], app);
}
let footer = Paragraph::new(format!(
"{} | speed={} (1/2/4/0) | create modal: m=manual p=cpu [ ] team Enter=create Esc=cancel | view modal: j/k scroll Esc/q close",
app.status_line,
app.speed.label()
))
.block(Block::default().borders(Borders::ALL).title("Status"))
.style(Style::default().fg(Color::Green));
f.render_widget(footer, areas[2]);
if let Some(draft) = &app.create_draft {
modal::render_create(f, f.area(), app, draft);
}

View file

@ -1,532 +0,0 @@
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 sc_logo_jpg() -> impl Responder {
HttpResponse::Ok()
.insert_header((header::CONTENT_TYPE, "image/jpeg"))
.body(include_bytes!("../sc-logo.jpg").as_slice())
}
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, listen_open: bool) -> io::Result<()> {
let shared = SharedState {
inner: Arc::new(Mutex::new(WebState::new(base_seed, speed))),
};
let ticker = shared.clone();
let bind_host = if listen_open { "0.0.0.0" } else { "127.0.0.1" };
let display_host = if listen_open {
"<machine-ip>"
} else {
"127.0.0.1"
};
println!(
"Starting SoccerCloud web UI at http://{display_host}:{WEB_PORT} (bound {bind_host}:{WEB_PORT}, seed={base_seed}, speed={})",
speed.label(),
);
if listen_open {
println!(
"Open listen enabled: accessible from other machines on your network at http://<machine-ip>:{WEB_PORT}"
);
}
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))
.route("/sc-logo.jpg", web::get().to(sc_logo_jpg))
.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((bind_host, WEB_PORT))?
.run()
.await
})
}