Compare commits
10 commits
dcdd06c8c3
...
fc99f1a622
| Author | SHA1 | Date | |
|---|---|---|---|
| fc99f1a622 | |||
| 000538b440 | |||
| 1762fa250f | |||
| fa48c81d29 | |||
| c12952d4b2 | |||
| 20568d2b3e | |||
| 591ab5ac4d | |||
| cc75f370fb | |||
| 2e191dea03 | |||
| 839b9f6c08 |
12 changed files with 3789 additions and 1002 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"
|
||||
crossterm = "0.28"
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
actix-web = "4.11"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
|
|
|
|||
28
DEVLOG.md
28
DEVLOG.md
|
|
@ -78,3 +78,31 @@
|
|||
- 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
262
README.md
|
|
@ -1,126 +1,198 @@
|
|||
# ⚽ SoccerCloud — Cloudified Soccer Simulation Environment
|
||||
# SoccerCloud CLI (Rust)
|
||||
|
||||
**Live Demo:** [https://mentalnet.xyz/soccercloud/](https://mentalnet.xyz/soccercloud/)
|
||||
**Author:** [markmental / MentalNet.xyz](https://mentalnet.xyz)
|
||||
**License:** MIT
|
||||
<p align="center">
|
||||
<img src="sc-logo.jpg" alt="SoccerCloud Logo" width="200">
|
||||
</p>
|
||||
|
||||
---
|
||||
Terminal-native rebuild of MentalNet SoccerCloud with a cloud-dashboard feel.
|
||||
|
||||
## 🧠 Overview
|
||||
## Overview
|
||||
|
||||
**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*.
|
||||
This project is a Rust TUI/CLI soccer simulator with:
|
||||
|
||||
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
|
||||
- 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`)
|
||||
|
||||
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.
|
||||
## Requirements
|
||||
|
||||
---
|
||||
- Rust toolchain (stable)
|
||||
- Cargo
|
||||
- A terminal that supports UTF-8 and colors
|
||||
|
||||
## 🌐 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
|
||||
Install Rust (if needed):
|
||||
|
||||
```bash
|
||||
python3 -m http.server 8080
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
|
||||
```
|
||||
|
||||
Then visit:
|
||||
👉 `http://localhost:8080`
|
||||
## Setup
|
||||
|
||||
---
|
||||
Clone and build:
|
||||
|
||||
## 🧩 Technical Notes
|
||||
```bash
|
||||
git clone https://mentalnet.xyz/forgejo/markmental/soccercloud-rust.git
|
||||
cd soccercloud-cli
|
||||
cargo check
|
||||
```
|
||||
|
||||
* 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.
|
||||
Run in debug mode:
|
||||
|
||||
---
|
||||
```bash
|
||||
cargo run
|
||||
```
|
||||
|
||||
## 🖥️ Upcoming: CLI Edition
|
||||
Build optimized binary:
|
||||
|
||||
> ⚡ **tuxsoccercloud** *(Coming soon!)*
|
||||
```bash
|
||||
cargo build --release
|
||||
./target/release/soccercloud
|
||||
```
|
||||
|
||||
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.
|
||||
## CLI Usage
|
||||
|
||||
Planned features:
|
||||
Default (interactive TUI):
|
||||
|
||||
* Text-only match recaps and league tables
|
||||
* Randomized or argument-based team selection
|
||||
* Fully offline operation
|
||||
```bash
|
||||
soccercloud
|
||||
```
|
||||
|
||||
---
|
||||
or with Cargo:
|
||||
|
||||
## 🤝 Contributing
|
||||
```bash
|
||||
cargo run --
|
||||
```
|
||||
|
||||
Pull requests are welcome (when I get signups up)!
|
||||
To contribute:
|
||||
Use a global seed for reproducibility:
|
||||
|
||||
1. Fork this repository
|
||||
2. Make your edits in a feature branch
|
||||
3. Submit a pull request with a clear description
|
||||
```bash
|
||||
cargo run -- --seed 42
|
||||
```
|
||||
|
||||
---
|
||||
### Web mode (Actix)
|
||||
|
||||
## 💡 Credits
|
||||
Launch the web UI on port `9009`:
|
||||
|
||||
* 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)
|
||||
```bash
|
||||
cargo run -- --web
|
||||
```
|
||||
|
||||
---
|
||||
Then open:
|
||||
|
||||
### ⚽ *"Deploy your next match like a VM — welcome to SoccerCloud."*
|
||||
```text
|
||||
http://127.0.0.1:9009
|
||||
```
|
||||
|
||||
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
514
data.js
|
|
@ -1,80 +1,444 @@
|
|||
// =======================================================
|
||||
// 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 state = {
|
||||
teams: [],
|
||||
simulations: [],
|
||||
selectedDetailId: null,
|
||||
pollHandle: null,
|
||||
};
|
||||
|
||||
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 },
|
||||
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 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 isThemeAllowed(theme) {
|
||||
return Object.prototype.hasOwnProperty.call(THEMES, theme);
|
||||
}
|
||||
|
||||
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();
|
||||
|
|
|
|||
1302
index.html
1302
index.html
File diff suppressed because it is too large
Load diff
BIN
sc-logo.jpg
Normal file
BIN
sc-logo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
737
src/data.rs
737
src/data.rs
|
|
@ -10,12 +10,6 @@ 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",
|
||||
|
|
@ -59,68 +53,566 @@ pub const TACTICS: [Tactic; 4] = [
|
|||
},
|
||||
];
|
||||
|
||||
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",
|
||||
#[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 fn team_flag(team: &str) -> &'static str {
|
||||
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" => "🇵🇹",
|
||||
_ => "🏳️",
|
||||
/// 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("🏳️")
|
||||
}
|
||||
|
||||
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()
|
||||
|
|
@ -130,126 +622,13 @@ pub fn tactic_by_key(key: &str) -> Tactic {
|
|||
}
|
||||
|
||||
pub fn profile_for(team: &str) -> 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 {
|
||||
team_by_name(team)
|
||||
.map(|t| TeamProfile {
|
||||
formation: t.formation,
|
||||
tactic: t.tactic,
|
||||
})
|
||||
.unwrap_or(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",
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
25
src/main.rs
25
src/main.rs
|
|
@ -5,6 +5,7 @@ mod instance;
|
|||
mod sim;
|
||||
mod ui;
|
||||
mod utils;
|
||||
mod web;
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::{self, Write};
|
||||
|
|
@ -16,6 +17,7 @@ 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")]
|
||||
|
|
@ -27,6 +29,12 @@ 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>,
|
||||
}
|
||||
|
|
@ -71,6 +79,23 @@ 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);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ 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 {
|
||||
|
|
@ -15,11 +14,7 @@ pub fn render(f: &mut Frame<'_>, area: Rect, app: &App) {
|
|||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3),
|
||||
Constraint::Min(8),
|
||||
Constraint::Length(2),
|
||||
])
|
||||
.constraints([Constraint::Length(3), Constraint::Min(8)])
|
||||
.split(area);
|
||||
|
||||
let score = Paragraph::new(inst.scoreboard.clone())
|
||||
|
|
@ -49,12 +44,6 @@ 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(""));
|
||||
|
|
@ -68,8 +57,4 @@ 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]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,11 +12,7 @@ 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),
|
||||
Constraint::Length(2),
|
||||
])
|
||||
.constraints([Constraint::Length(3), Constraint::Min(10)])
|
||||
.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")
|
||||
|
|
@ -30,15 +26,6 @@ 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);
|
||||
}
|
||||
|
|
|
|||
532
src/web.rs
Normal file
532
src/web.rs
Normal file
|
|
@ -0,0 +1,532 @@
|
|||
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
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue