Compare commits
No commits in common. "fc99f1a6223649d8cd20fe31cb1c54c733e8be2d" and "dcdd06c8c3f9a76e7d6afdc823799462c462dceb" have entirely different histories.
fc99f1a622
...
dcdd06c8c3
12 changed files with 1002 additions and 3789 deletions
1357
Cargo.lock
generated
1357
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -7,8 +7,6 @@ edition = "2021"
|
||||||
ratatui = "0.29"
|
ratatui = "0.29"
|
||||||
crossterm = "0.28"
|
crossterm = "0.28"
|
||||||
clap = { version = "4.5", features = ["derive"] }
|
clap = { version = "4.5", features = ["derive"] }
|
||||||
actix-web = "4.11"
|
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
|
|
|
||||||
28
DEVLOG.md
28
DEVLOG.md
|
|
@ -78,31 +78,3 @@
|
||||||
- Added modal scrolling controls (`j/k` or up/down) and close controls (`Esc` or `q`).
|
- 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.
|
- Simplified detail view to focus on scoreboard, logs, and instance info.
|
||||||
- Added detail-panel hint bar to direct users to the new dedicated modals.
|
- 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,198 +1,126 @@
|
||||||
# SoccerCloud CLI (Rust)
|
# ⚽ SoccerCloud — Cloudified Soccer Simulation Environment
|
||||||
|
|
||||||
<p align="center">
|
**Live Demo:** [https://mentalnet.xyz/soccercloud/](https://mentalnet.xyz/soccercloud/)
|
||||||
<img src="sc-logo.jpg" alt="SoccerCloud Logo" width="200">
|
**Author:** [markmental / MentalNet.xyz](https://mentalnet.xyz)
|
||||||
</p>
|
**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
|
Each match, league, or knockout bracket behaves like a **“virtual instance”**, complete with lifecycle controls:
|
||||||
- Live match logs, scoreboard, and instance lifecycle controls
|
- **Create / Start / View / Delete / Clone / Export**
|
||||||
- Seeded deterministic runs (`--seed`) for reproducible results
|
- Real-time logs, xG data, formations, and tactical analytics
|
||||||
- CSV export for single, league, and knockout outputs
|
- **Dynamic UI** styled like a cloud console with per-match telemetry
|
||||||
- Expanded team pool (clubs + 50+ national teams, including `PRC China`)
|
|
||||||
|
|
||||||
## 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
|
```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
|
## 🧩 Technical Notes
|
||||||
git clone https://mentalnet.xyz/forgejo/markmental/soccercloud-rust.git
|
|
||||||
cd soccercloud-cli
|
|
||||||
cargo check
|
|
||||||
```
|
|
||||||
|
|
||||||
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
|
> ⚡ **tuxsoccercloud** *(Coming soon!)*
|
||||||
cargo build --release
|
|
||||||
./target/release/soccercloud
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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
|
* Text-only match recaps and league tables
|
||||||
soccercloud
|
* Randomized or argument-based team selection
|
||||||
```
|
* Fully offline operation
|
||||||
|
|
||||||
or with Cargo:
|
---
|
||||||
|
|
||||||
```bash
|
## 🤝 Contributing
|
||||||
cargo run --
|
|
||||||
```
|
|
||||||
|
|
||||||
Use a global seed for reproducibility:
|
Pull requests are welcome (when I get signups up)!
|
||||||
|
To contribute:
|
||||||
|
|
||||||
```bash
|
1. Fork this repository
|
||||||
cargo run -- --seed 42
|
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
|
* Built and designed by **markmental**
|
||||||
cargo run -- --web
|
* 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
|
### ⚽ *"Deploy your next match like a VM — welcome to SoccerCloud."*
|
||||||
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,444 +1,80 @@
|
||||||
const state = {
|
// =======================================================
|
||||||
teams: [],
|
// SoccerCloud Simulator Data File
|
||||||
simulations: [],
|
// Contains all team, flag, and profile information.
|
||||||
selectedDetailId: null,
|
// =======================================================
|
||||||
pollHandle: null,
|
|
||||||
|
const teams = [
|
||||||
|
// J-League
|
||||||
|
"Kashima Antlers","Urawa Red Diamonds","Gamba Osaka","Cerezo Osaka","Kawasaki Frontale",
|
||||||
|
"Yokohama F. Marinos","Nagoya Grampus","Shimizu S-Pulse","Sanfrecce Hiroshima","Consadole Sapporo",
|
||||||
|
"Ventforet Kofu","Tokyo Verdy","JEF United Chiba",
|
||||||
|
// Euro clubs
|
||||||
|
"Arsenal","FC Barcelona","Real Madrid","Manchester City","Manchester United","Liverpool",
|
||||||
|
"Bayern Munich","Borussia Dortmund","Paris Saint-Germain","Juventus","Inter","AC Milan",
|
||||||
|
"Ajax","Benfica","Porto","Celtic"
|
||||||
|
];
|
||||||
|
|
||||||
|
const TEAM_FLAGS = {
|
||||||
|
// Japan
|
||||||
|
"Kashima Antlers":"🇯🇵","Urawa Red Diamonds":"🇯🇵","Gamba Osaka":"🇯🇵","Cerezo Osaka":"🇯🇵","Kawasaki Frontale":"🇯🇵",
|
||||||
|
"Yokohama F. Marinos":"🇯🇵","Nagoya Grampus":"🇯🇵","Shimizu S-Pulse":"🇯🇵","Sanfrecce Hiroshima":"🇯🇵","Consadole Sapporo":"🇯🇵",
|
||||||
|
"Ventforet Kofu":"🇯🇵","Tokyo Verdy":"🇯🇵", "JEF United Chiba":"🇯🇵",
|
||||||
|
// UK
|
||||||
|
"Arsenal":"🇬🇧","Manchester City":"🇬🇧","Manchester United":"🇬🇧","Liverpool":"🇬🇧","Celtic":"🇬🇧",
|
||||||
|
// Spain
|
||||||
|
"FC Barcelona":"🇪🇸","Real Madrid":"🇪🇸",
|
||||||
|
// Germany
|
||||||
|
"Bayern Munich":"🇩🇪","Borussia Dortmund":"🇩🇪",
|
||||||
|
// France
|
||||||
|
"Paris Saint-Germain":"🇫🇷",
|
||||||
|
// Italy
|
||||||
|
"Juventus":"🇮🇹","Inter":"🇮🇹","AC Milan":"🇮🇹",
|
||||||
|
// Netherlands
|
||||||
|
"Ajax":"🇳🇱",
|
||||||
|
// Portugal
|
||||||
|
"Benfica":"🇵🇹","Porto":"🇵🇹"
|
||||||
};
|
};
|
||||||
|
|
||||||
const $ = (id) => document.getElementById(id);
|
const FORMATIONS = ["4-4-2","4-3-3","4-2-3-1","3-5-2","5-4-1"];
|
||||||
const THEME_STORAGE_KEY = "soccercloud.web.theme";
|
|
||||||
const THEMES = {
|
const TACTICS = {
|
||||||
"default-light": "Default (Light)",
|
counter: { label:"Counter", attackBias:1.10, goalMult:1.08, fastBreak:0.25, foulMult:1.00, blockMult:1.00, pressMult:0.95 },
|
||||||
dark: "Dark",
|
possession: { label:"Possession", attackBias:1.00, goalMult:0.95, fastBreak:0.10, foulMult:0.90, blockMult:1.00, pressMult:0.90 },
|
||||||
matcha: "Matcha",
|
high_press: { label:"High Press", attackBias:1.15, goalMult:1.00, fastBreak:0.20, foulMult:1.20, blockMult:0.95, pressMult:1.20 },
|
||||||
"black-mesa": "Black Mesa",
|
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) {
|
const TEAM_PROFILES = {
|
||||||
return Object.prototype.hasOwnProperty.call(THEMES, theme);
|
// 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
1286
index.html
File diff suppressed because it is too large
Load diff
BIN
sc-logo.jpg
BIN
sc-logo.jpg
Binary file not shown.
|
Before Width: | Height: | Size: 32 KiB |
737
src/data.rs
737
src/data.rs
|
|
@ -10,6 +10,12 @@ pub struct Tactic {
|
||||||
pub press_mult: f64,
|
pub press_mult: f64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct TeamProfile {
|
||||||
|
pub formation: &'static str,
|
||||||
|
pub tactic: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
pub const TACTICS: [Tactic; 4] = [
|
pub const TACTICS: [Tactic; 4] = [
|
||||||
Tactic {
|
Tactic {
|
||||||
key: "counter",
|
key: "counter",
|
||||||
|
|
@ -53,566 +59,68 @@ pub const TACTICS: [Tactic; 4] = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
pub const TEAMS: [&str; 29] = [
|
||||||
pub struct Team {
|
"Kashima Antlers",
|
||||||
pub name: &'static str,
|
"Urawa Red Diamonds",
|
||||||
pub flag: &'static str,
|
"Gamba Osaka",
|
||||||
pub formation: &'static str,
|
"Cerezo Osaka",
|
||||||
pub tactic: &'static str,
|
"Kawasaki Frontale",
|
||||||
}
|
"Yokohama F. Marinos",
|
||||||
|
"Nagoya Grampus",
|
||||||
pub const TEAMS_DATA: [Team; 85] = [
|
"Shimizu S-Pulse",
|
||||||
// J-League Clubs
|
"Sanfrecce Hiroshima",
|
||||||
Team {
|
"Consadole Sapporo",
|
||||||
name: "Kashima Antlers",
|
"Ventforet Kofu",
|
||||||
flag: "🇯🇵",
|
"Tokyo Verdy",
|
||||||
formation: "4-4-2",
|
"JEF United Chiba",
|
||||||
tactic: "counter",
|
"Arsenal",
|
||||||
},
|
"FC Barcelona",
|
||||||
Team {
|
"Real Madrid",
|
||||||
name: "Urawa Red Diamonds",
|
"Manchester City",
|
||||||
flag: "🇯🇵",
|
"Manchester United",
|
||||||
formation: "4-2-3-1",
|
"Liverpool",
|
||||||
tactic: "possession",
|
"Bayern Munich",
|
||||||
},
|
"Borussia Dortmund",
|
||||||
Team {
|
"Paris Saint-Germain",
|
||||||
name: "Gamba Osaka",
|
"Juventus",
|
||||||
flag: "🇯🇵",
|
"Inter",
|
||||||
formation: "4-4-2",
|
"AC Milan",
|
||||||
tactic: "counter",
|
"Ajax",
|
||||||
},
|
"Benfica",
|
||||||
Team {
|
"Porto",
|
||||||
name: "Cerezo Osaka",
|
"Celtic",
|
||||||
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",
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/// 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 {
|
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 {
|
pub fn display_name(team: &str) -> String {
|
||||||
format!("{} {}", team_flag(team), team)
|
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 {
|
pub fn tactic_by_key(key: &str) -> Tactic {
|
||||||
TACTICS
|
TACTICS
|
||||||
.iter()
|
.iter()
|
||||||
|
|
@ -622,13 +130,126 @@ pub fn tactic_by_key(key: &str) -> Tactic {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn profile_for(team: &str) -> TeamProfile {
|
pub fn profile_for(team: &str) -> TeamProfile {
|
||||||
team_by_name(team)
|
match team {
|
||||||
.map(|t| TeamProfile {
|
"Arsenal" => TeamProfile {
|
||||||
formation: t.formation,
|
formation: "4-3-3",
|
||||||
tactic: t.tactic,
|
tactic: "possession",
|
||||||
})
|
},
|
||||||
.unwrap_or(TeamProfile {
|
"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",
|
formation: "4-4-2",
|
||||||
tactic: "counter",
|
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,7 +5,6 @@ mod instance;
|
||||||
mod sim;
|
mod sim;
|
||||||
mod ui;
|
mod ui;
|
||||||
mod utils;
|
mod utils;
|
||||||
mod web;
|
|
||||||
|
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io::{self, Write};
|
use std::io::{self, Write};
|
||||||
|
|
@ -17,7 +16,6 @@ use data::{display_name, TEAMS};
|
||||||
use export::simulation_to_csv_bytes;
|
use export::simulation_to_csv_bytes;
|
||||||
use sim::{run_simulation, SimulationType};
|
use sim::{run_simulation, SimulationType};
|
||||||
use utils::{derive_seed, Rng};
|
use utils::{derive_seed, Rng};
|
||||||
use web::run_web_server;
|
|
||||||
|
|
||||||
#[derive(Debug, Parser)]
|
#[derive(Debug, Parser)]
|
||||||
#[command(name = "soccercloud")]
|
#[command(name = "soccercloud")]
|
||||||
|
|
@ -29,12 +27,6 @@ struct Cli {
|
||||||
#[arg(long, global = true, value_enum, default_value_t = Speed::X1)]
|
#[arg(long, global = true, value_enum, default_value_t = Speed::X1)]
|
||||||
speed: Speed,
|
speed: Speed,
|
||||||
|
|
||||||
#[arg(long, global = true)]
|
|
||||||
web: bool,
|
|
||||||
|
|
||||||
#[arg(long, global = true)]
|
|
||||||
listen_open: bool,
|
|
||||||
|
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: Option<Commands>,
|
command: Option<Commands>,
|
||||||
}
|
}
|
||||||
|
|
@ -79,23 +71,6 @@ fn main() -> io::Result<()> {
|
||||||
let cli = Cli::parse();
|
let cli = Cli::parse();
|
||||||
let base_seed = cli.seed.unwrap_or_else(|| Rng::from_time().next_u64());
|
let base_seed = cli.seed.unwrap_or_else(|| Rng::from_time().next_u64());
|
||||||
|
|
||||||
if cli.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 {
|
match cli.command {
|
||||||
None => {
|
None => {
|
||||||
let app = App::new(base_seed, cli.speed);
|
let app = App::new(base_seed, cli.speed);
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ use ratatui::prelude::*;
|
||||||
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph};
|
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph};
|
||||||
|
|
||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
|
use crate::instance::SimStatus;
|
||||||
|
|
||||||
pub fn render(f: &mut Frame<'_>, area: Rect, app: &App) {
|
pub fn render(f: &mut Frame<'_>, area: Rect, app: &App) {
|
||||||
let Some(inst) = app.selected_instance() else {
|
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()
|
let chunks = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([Constraint::Length(3), Constraint::Min(8)])
|
.constraints([
|
||||||
|
Constraint::Length(3),
|
||||||
|
Constraint::Min(8),
|
||||||
|
Constraint::Length(2),
|
||||||
|
])
|
||||||
.split(area);
|
.split(area);
|
||||||
|
|
||||||
let score = Paragraph::new(inst.scoreboard.clone())
|
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]);
|
f.render_widget(log_widget, middle[0]);
|
||||||
|
|
||||||
let mut right_lines: Vec<ListItem> = Vec::new();
|
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!("Seed: {}", inst.seed)));
|
||||||
right_lines.push(ListItem::new(format!("Mode: {}", inst.sim_type.as_str())));
|
right_lines.push(ListItem::new(format!("Mode: {}", inst.sim_type.as_str())));
|
||||||
right_lines.push(ListItem::new(""));
|
right_lines.push(ListItem::new(""));
|
||||||
|
|
@ -57,4 +68,8 @@ pub fn render(f: &mut Frame<'_>, area: Rect, app: &App) {
|
||||||
.borders(Borders::ALL),
|
.borders(Borders::ALL),
|
||||||
);
|
);
|
||||||
f.render_widget(side, middle[1]);
|
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,7 +12,11 @@ use crate::app::App;
|
||||||
pub fn draw(f: &mut Frame<'_>, app: &App) {
|
pub fn draw(f: &mut Frame<'_>, app: &App) {
|
||||||
let areas = Layout::default()
|
let areas = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([Constraint::Length(3), Constraint::Min(10)])
|
.constraints([
|
||||||
|
Constraint::Length(3),
|
||||||
|
Constraint::Min(10),
|
||||||
|
Constraint::Length(2),
|
||||||
|
])
|
||||||
.split(f.area());
|
.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")
|
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);
|
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 {
|
if let Some(draft) = &app.create_draft {
|
||||||
modal::render_create(f, f.area(), app, draft);
|
modal::render_create(f, f.area(), app, draft);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
532
src/web.rs
532
src/web.rs
|
|
@ -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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue