Initial Commit
This commit is contained in:
126
README.md
Normal file
126
README.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# ⚽ SoccerCloud — Cloudified Soccer Simulation Environment
|
||||
|
||||
**Live Demo:** [https://mentalnet.xyz/soccercloud/](https://mentalnet.xyz/soccercloud/)
|
||||
**Author:** [markmental / MentalNet.xyz](https://mentalnet.xyz)
|
||||
**License:** MIT
|
||||
|
||||
---
|
||||
|
||||
## 🧠 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*.
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 🌐 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
|
||||
python3 -m http.server 8080
|
||||
```
|
||||
|
||||
Then visit:
|
||||
👉 `http://localhost:8080`
|
||||
|
||||
---
|
||||
|
||||
## 🧩 Technical Notes
|
||||
|
||||
* 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.
|
||||
|
||||
---
|
||||
|
||||
## 🖥️ Upcoming: CLI Edition
|
||||
|
||||
> ⚡ **tuxsoccercloud** *(Coming soon!)*
|
||||
|
||||
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.
|
||||
|
||||
Planned features:
|
||||
|
||||
* Text-only match recaps and league tables
|
||||
* Randomized or argument-based team selection
|
||||
* Fully offline operation
|
||||
|
||||
---
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
Pull requests are welcome (when I get signups up)!
|
||||
To contribute:
|
||||
|
||||
1. Fork this repository
|
||||
2. Make your edits in a feature branch
|
||||
3. Submit a pull request with a clear description
|
||||
|
||||
---
|
||||
|
||||
## 💡 Credits
|
||||
|
||||
* 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)
|
||||
|
||||
---
|
||||
|
||||
### ⚽ *"Deploy your next match like a VM — welcome to SoccerCloud."*
|
||||
|
||||
80
data.js
Normal file
80
data.js
Normal file
@@ -0,0 +1,80 @@
|
||||
// =======================================================
|
||||
// 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 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 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" }
|
||||
};
|
||||
|
||||
650
index.html
Normal file
650
index.html
Normal file
@@ -0,0 +1,650 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MentalNet.xyz | SoccerCloud Dashboard</title>
|
||||
<script src="data.js"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=UnifrakturCook:wght@700&family=Press+Start+2P&family=Roboto:wght@300;400;700&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" />
|
||||
<style>
|
||||
:root {
|
||||
--bg-color: #000;
|
||||
--text-color: white;
|
||||
--accent-color: #007cffab;
|
||||
--secondary-color: rgba(255,255,255,0.1);
|
||||
--header-font: 'UnifrakturCook', cursive;
|
||||
--body-font: 'Press Start 2P', monospace;
|
||||
--lime: lime;
|
||||
--red: #ff4141;
|
||||
}
|
||||
#matchLog {
|
||||
font-size: xx-small;
|
||||
text-decoration: underline white;
|
||||
line-height: 20px;
|
||||
}
|
||||
.tagline {
|
||||
text-transform: full-width;
|
||||
font-size: 14px;
|
||||
word-break: keep-all;
|
||||
}
|
||||
a { color: var(--lime); }
|
||||
a:hover { text-shadow: 0 0 5px var(--lime), 0 0 10px var(--lime); }
|
||||
.fas { color: var(--lime); }
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { background-image: radial-gradient(ellipse at top, #0a141f 0%, #000000 75%); color: var(--text-color); font-family: var(--body-font); line-height: 1.6; }
|
||||
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
|
||||
header { text-align: center; padding: 20px; border-bottom: 2px solid var(--accent-color); margin-bottom: 30px; }
|
||||
h1 { font-family: var(--header-font); font-size: 2.5rem; color: var(--accent-color); }
|
||||
h2 { font-family: var(--header-font); font-size: 2rem; color: var(--accent-color); margin: 30px 0 15px; border-bottom: 1px solid var(--secondary-color); }
|
||||
.btn { padding: 8px 14px; background: var(--accent-color); border: none; cursor: pointer; color: white; margin: 5px; font-family: var(--body-font); }
|
||||
.btn:hover { opacity: 0.8; }
|
||||
.btn.btn-danger { background: #9d0000; color: white; }
|
||||
|
||||
/* Dashboard Structure */
|
||||
.dashboard-controls { display: flex; justify-content: flex-end; margin-bottom: 20px; }
|
||||
.dashboard-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 20px; }
|
||||
.instance-card { background: rgba(20,20,20,0.8); border: 1px solid var(--secondary-color); border-left: 4px solid var(--accent-color); padding: 15px; display: flex; flex-direction: column; }
|
||||
.instance-card .header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
||||
.instance-card .title { font-size: 0.9rem; }
|
||||
.instance-card .id { font-size: 0.7rem; opacity: 0.6; }
|
||||
.instance-card .body { flex-grow: 1; }
|
||||
.instance-card .status { font-size: 0.8rem; margin-bottom: 10px; padding: 4px 8px; display: inline-block; border-radius: 4px; }
|
||||
.instance-card .status.pending { background: #6c757d; color: white; }
|
||||
.instance-card .status.running { background: #198754; color: white; }
|
||||
.instance-card .status.completed { background: #0d6efd; color: white; }
|
||||
.instance-card .progress-text { font-size: 0.8rem; height: 3em; overflow: hidden; }
|
||||
.instance-card .footer { margin-top: 15px; display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; }
|
||||
.instance-card .footer .btn { padding: 5px 10px; font-size: 0.7rem; }
|
||||
|
||||
/* Modal Styles */
|
||||
.modal { display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; overflow: auto; background-color: rgba(0,0,0,0.8); }
|
||||
.modal-content { background-color: #111; margin: 5% auto; padding: 20px; border: 2px solid var(--lime); width: 80%; max-width: 900px; position: relative; }
|
||||
.modal-header { border-bottom: 1px solid var(--secondary-color); padding-bottom: 10px; margin-bottom: 20px; }
|
||||
.close-btn { color: #aaa; float: right; font-size: 28px; font-weight: bold; cursor: pointer; }
|
||||
.close-btn:hover { color: white; }
|
||||
|
||||
/* Detail View Modal Content */
|
||||
#detail-modal .scoreboard { text-align: center; margin-bottom: 10px; font-size: 1.1rem; padding: 10px; background: #000; }
|
||||
#detail-modal .match-log { max-height: 300px; overflow-y: auto; background: #050505; padding: 10px; border: 1px solid var(--secondary-color); font-size: 0.8rem; line-height: 1.4; margin-bottom: 15px; }
|
||||
.stats-wrap, .competition-panel, .history { margin-top: 15px; }
|
||||
|
||||
/* Form Styles (in modal) */
|
||||
.sim-controls { display:flex; flex-wrap:wrap; gap:10px; align-items:center; margin-bottom:10px; }
|
||||
.sim-controls select { background:#222; color:var(--text-color); border:1px solid var(--secondary-color); padding:6px 8px; font-family: var(--body-font); }
|
||||
.team-pickers { display:grid; gap:8px; margin-bottom:10px; }
|
||||
.picker-row { display:flex; flex-wrap:wrap; gap:10px; }
|
||||
.picker-row select { background:#222; color:var(--text-color); border:1px solid var(--secondary-color); padding:6px 8px; font-family: var(--body-font); }
|
||||
|
||||
/* Shared Stats Table Styling */
|
||||
.stats-table, .standings-table { width: 100%; display: block; overflow-x: auto; border-collapse: collapse; font-size: 12px; }
|
||||
.stats-table th, .stats-table td, .standings-table th, .standings-table td { border: 1px solid var(--secondary-color); padding: 6px 8px; text-align: center; }
|
||||
.stats-table th, .standings-table th { background: rgba(0,0,0,0.5); color: var(--accent-color); }
|
||||
.meta { font-size: 0.8rem; opacity: 0.9; margin-bottom: 6px; }
|
||||
.recap { margin-top: 14px; padding: 12px; border: 1px dashed var(--secondary-color); background: rgba(10,10,10,0.8); }
|
||||
.recap-title { font-family: var(--header-font); color: var(--accent-color); margin-bottom: 6px; text-shadow: 1px 1px lime; }
|
||||
.history .item { border:1px dashed var(--secondary-color); padding:6px 8px; margin-bottom:6px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<h1>MentalNet SoccerCloud</h1>
|
||||
<p class="tagline">Cloudified Soccer Simulation Environment</p>
|
||||
</header>
|
||||
|
||||
<section id="simulation-dashboard">
|
||||
<h2><i class="fas fa-server"></i> Simulation Instances</h2>
|
||||
<div class="dashboard-controls">
|
||||
<button class="btn" id="showCreateModalBtn"><i class="fas fa-plus-circle"></i> Create Simulation</button>
|
||||
</div>
|
||||
<div class="dashboard-grid" id="dashboardGrid">
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="createModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<span class="close-btn" data-modal="createModal">×</span>
|
||||
<h2>Configure New Simulation</h2>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="sim-controls">
|
||||
<label>Mode:
|
||||
<select id="modeSelect">
|
||||
<option value="single">Single Match</option>
|
||||
<option value="league4">4–Team League</option>
|
||||
<option value="knockout4">4–Team Knockout</option>
|
||||
</select>
|
||||
</label>
|
||||
<label><input type="checkbox" id="autoPick" checked> Auto‑pick teams</label>
|
||||
</div>
|
||||
<div class="team-pickers" id="teamPickers">
|
||||
<div class="picker-row picker-single">
|
||||
<label>Home: <select id="teamHome"></select></label>
|
||||
<label>Away: <select id="teamAway"></select></label>
|
||||
</div>
|
||||
<div class="picker-row picker-group" style="display:none">
|
||||
<label>Team A: <select id="team1"></select></label>
|
||||
<label>Team B: <select id="team2"></select></label>
|
||||
<label>Team C: <select id="team3"></select></label>
|
||||
<label>Team D: <select id="team4"></select></label>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn" id="launchBtn" type="button"><i class="fas fa-rocket"></i> Create Instance</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="detailModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<span class="close-btn" data-modal="detailModal">×</span>
|
||||
<h2 id="detailModalTitle">Simulation Details</h2>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="scoreboard" id="scoreboard"></div>
|
||||
<div class="match-log" id="matchLog"></div>
|
||||
<div class="stats-wrap" id="matchStats"></div>
|
||||
<div class="competition-panel" id="competitionPanel"></div>
|
||||
<div class="history" id="history"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// === HELPERS (Unchanged) ===
|
||||
const $ = (id)=>document.getElementById(id);
|
||||
function displayName(team){ return (TEAM_FLAGS[team] || "🏳️") + " " + team; }
|
||||
function randomPick(arr){ return arr[Math.floor(Math.random()*arr.length)]; }
|
||||
function profileFor(team){ return TEAM_PROFILES[team] || { formation: randomPick(FORMATIONS), tactic: randomPick(Object.keys(TACTICS)) }; }
|
||||
function fmt(m){ return String(m).padStart(2,'0'); }
|
||||
function uniqueRandomTeams(n){ const pool=[...teams], picked=[]; while(picked.length<n && pool.length){ const i=Math.floor(Math.random()*pool.length); picked.push(pool.splice(i,1)[0]); } return picked; }
|
||||
function penalties(home, away){ let h=0,a=0; for(let i=0;i<5;i++){ if(Math.random()<0.76) h++; if(Math.random()<0.76) a++; } let i=0; while(h===a){ if(Math.random()<0.76) h++; if(Math.random()<0.76) a++; if(i>20) break; } return {home:h, away:a, winner: h>a ? 'home' : 'away'};}
|
||||
|
||||
// === NEW: SIMULATION INSTANCE & MANAGER ===
|
||||
class SimulationInstance {
|
||||
constructor(id, type, teams) {
|
||||
this.id = id;
|
||||
this.type = type;
|
||||
this.teams = teams;
|
||||
this.status = 'pending'; // pending, running, completed
|
||||
this.progressText = 'Ready to start.';
|
||||
this.result = null;
|
||||
|
||||
// Data stores for detail view
|
||||
this.logData = [];
|
||||
this.scoreboardData = 'Waiting for kickoff...';
|
||||
this.statsData = '';
|
||||
this.competitionData = '';
|
||||
this.historyData = [];
|
||||
}
|
||||
|
||||
get title() {
|
||||
switch(this.type) {
|
||||
case 'single': return `Match: ${this.teams[0]} vs ${this.teams[1]}`;
|
||||
case 'league4': return `4-Team League`;
|
||||
case 'knockout4': return `4-Team Knockout`;
|
||||
default: return 'Unknown Simulation';
|
||||
}
|
||||
}
|
||||
|
||||
renderCard() {
|
||||
return `
|
||||
<div class="instance-card" id="instance-${this.id}" data-id="${this.id}">
|
||||
<div class="header">
|
||||
<div class="title-block">
|
||||
<div class="title">${this.title}</div>
|
||||
<div class="id">ID: sim-${this.id}</div>
|
||||
</div>
|
||||
<span class="status ${this.status}">${this.status}</span>
|
||||
</div>
|
||||
<div class="body">
|
||||
<div class="progress-text">${this.progressText}</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
${this.status === 'pending' ? `<button class="btn btn-start"><i class="fas fa-play"></i> Start</button>` : ''}
|
||||
${this.status === 'completed' ? `<button class="btn btn-export"><i class="fas fa-file-csv"></i> Export</button>` : ''}
|
||||
<button class="btn btn-clone"><i class="fas fa-clone"></i> Clone</button>
|
||||
<button class="btn btn-view"><i class="fas fa-eye"></i> View</button>
|
||||
<button class="btn btn-danger btn-delete"><i class="fas fa-trash"></i> Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
updateCard() {
|
||||
const card = $(`instance-${this.id}`);
|
||||
if (!card) return;
|
||||
card.querySelector('.status').textContent = this.status;
|
||||
card.querySelector('.status').className = `status ${this.status}`;
|
||||
card.querySelector('.progress-text').innerHTML = this.progressText;
|
||||
if (this.status !== 'pending') {
|
||||
const startBtn = card.querySelector('.btn-start');
|
||||
if(startBtn) startBtn.remove();
|
||||
}
|
||||
}
|
||||
|
||||
async run() {
|
||||
if (this.status !== 'pending') return;
|
||||
this.status = 'running';
|
||||
this.updateCard();
|
||||
|
||||
const callbacks = {
|
||||
onLog: (msg) => { this.logData.push(msg); },
|
||||
onScoreboard: (html) => {
|
||||
this.scoreboardData = html;
|
||||
this.progressText = html.replace(/<[^>]*>/g, '');
|
||||
this.updateCard();
|
||||
},
|
||||
onStats: (html) => { this.statsData = html; },
|
||||
onCompetitionUpdate: (html, type) => {
|
||||
if (type === 'table') this.competitionData = html;
|
||||
if (type === 'bracket') this.competitionData = html;
|
||||
},
|
||||
onHistoryUpdate: (item) => { this.historyData.push(item); },
|
||||
onProgress: (text) => {
|
||||
this.progressText = text;
|
||||
this.updateCard();
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
if (this.type === 'single') {
|
||||
this.result = await runSingle(this.teams[0], this.teams[1], callbacks);
|
||||
} else if (this.type === 'league4') {
|
||||
this.result = await runLeague4(this.teams, callbacks);
|
||||
this.progressText = `<strong>Champion:</strong> ${displayName(this.result.champion.team)}`;
|
||||
} else if (this.type === 'knockout4') {
|
||||
this.result = await runKnockout4(this.teams, callbacks);
|
||||
this.progressText = `<strong>Champion:</strong> ${displayName(this.result.champion)} 🏆`;
|
||||
}
|
||||
this.status = 'completed';
|
||||
} catch (e) {
|
||||
console.error(`Simulation ${this.id} failed:`, e);
|
||||
this.status = 'failed';
|
||||
this.progressText = 'Error during simulation.';
|
||||
}
|
||||
|
||||
this.updateCard();
|
||||
SimulationManager.renderDashboard();
|
||||
}
|
||||
|
||||
exportToCSV() {
|
||||
if (this.status !== 'completed' || !this.result) {
|
||||
console.error("Simulation not complete. Cannot export.");
|
||||
return;
|
||||
}
|
||||
|
||||
let csvContent = "";
|
||||
let fileName = `sim-${this.id}-${this.type}-results.csv`;
|
||||
|
||||
const download = (content, name) => {
|
||||
const blob = new Blob([content], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement("a");
|
||||
const url = URL.createObjectURL(blob);
|
||||
link.setAttribute("href", url);
|
||||
link.setAttribute("download", name);
|
||||
link.style.visibility = 'hidden';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
switch (this.type) {
|
||||
case 'single': {
|
||||
const { home, away, homeGoals, awayGoals, stats } = this.result;
|
||||
const headers = "Category,Home Team,Away Team\n";
|
||||
const teamRow = `Team,${home},${away}\n`;
|
||||
const scoreRow = `Goals,${homeGoals},${awayGoals}\n`;
|
||||
const statsHeaders = "Stat,Home Value,Away Value\n";
|
||||
const statsRows = [
|
||||
`Shots,${stats.home.shots},${stats.away.shots}`,
|
||||
`Shots on Target,${stats.home.sot},${stats.away.sot}`,
|
||||
`xG,${stats.home.xg.toFixed(2)},${stats.away.xg.toFixed(2)}`,
|
||||
`Corners,${stats.home.corners},${stats.away.corners}`,
|
||||
`Fouls,${stats.home.fouls},${stats.away.fouls}`,
|
||||
`Yellow Cards,${stats.home.yellows},${stats.away.yellows}`,
|
||||
`Saves,${stats.home.saves},${stats.away.saves}`
|
||||
].join('\n');
|
||||
csvContent = headers + teamRow + scoreRow + "\n" + statsHeaders + statsRows;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'league4': {
|
||||
const { finalTable } = this.result;
|
||||
const headers = "Team,P,W,D,L,GF,GA,GD,Pts\n";
|
||||
const sorted = Object.values(finalTable).sort((a,b) => b.PTS - a.PTS || (b.GD - a.GD));
|
||||
const rows = sorted.map(r =>
|
||||
`${r.team},${r.P},${r.W},${r.D},${r.L},${r.GF},${r.GA},${r.GD},${r.PTS}`
|
||||
).join('\n');
|
||||
csvContent = headers + rows;
|
||||
break;
|
||||
}
|
||||
|
||||
case 'knockout4': {
|
||||
const headers = "Stage,Match Result\n";
|
||||
const rows = this.historyData
|
||||
.filter(item => item.includes('<strong>'))
|
||||
.map(item => {
|
||||
const cleanItem = item.replace(/<strong>|<\/strong>|🏆/g, '').replace(':', ',');
|
||||
return cleanItem;
|
||||
})
|
||||
.join('\n');
|
||||
csvContent = headers + rows;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
download(csvContent, fileName);
|
||||
}
|
||||
}
|
||||
|
||||
const SimulationManager = {
|
||||
simulations: {},
|
||||
nextId: 0,
|
||||
|
||||
create(type, teams) {
|
||||
const id = this.nextId++;
|
||||
const instance = new SimulationInstance(id, type, teams);
|
||||
this.simulations[id] = instance;
|
||||
this.renderDashboard();
|
||||
return instance;
|
||||
},
|
||||
|
||||
delete(id) {
|
||||
delete this.simulations[id];
|
||||
this.renderDashboard();
|
||||
},
|
||||
|
||||
start(id) {
|
||||
this.simulations[id]?.run();
|
||||
},
|
||||
|
||||
view(id) {
|
||||
const instance = this.simulations[id];
|
||||
if (!instance) return;
|
||||
|
||||
$('detailModalTitle').textContent = `Details for sim-${id}: ${instance.title}`;
|
||||
|
||||
$('scoreboard').innerHTML = instance.scoreboardData;
|
||||
$('matchLog').innerHTML = instance.logData.map(msg => `<p>${msg}</p>`).join('');
|
||||
$('matchLog').scrollTop = $('matchLog').scrollHeight;
|
||||
$('matchStats').innerHTML = instance.statsData;
|
||||
$('competitionPanel').innerHTML = instance.competitionData;
|
||||
$('history').innerHTML = instance.historyData.map(item => `<div class="item">${item}</div>`).join('');
|
||||
|
||||
detailModal.style.display = 'block';
|
||||
},
|
||||
|
||||
export(id) {
|
||||
this.simulations[id]?.exportToCSV();
|
||||
},
|
||||
|
||||
// ADDED: Method to handle cloning an instance
|
||||
clone(id) {
|
||||
const instanceToClone = this.simulations[id];
|
||||
if (!instanceToClone) return;
|
||||
|
||||
// Set the mode
|
||||
modeSelect.value = instanceToClone.type;
|
||||
// Important: Trigger change event to update the UI (team pickers)
|
||||
modeSelect.dispatchEvent(new Event('change'));
|
||||
|
||||
// Pre-fill team selections
|
||||
if (instanceToClone.type === 'single') {
|
||||
teamHome.value = instanceToClone.teams[0];
|
||||
teamAway.value = instanceToClone.teams[1];
|
||||
} else { // league4 or knockout4
|
||||
team1.value = instanceToClone.teams[0];
|
||||
team2.value = instanceToClone.teams[1];
|
||||
team3.value = instanceToClone.teams[2];
|
||||
team4.value = instanceToClone.teams[3];
|
||||
}
|
||||
|
||||
// Uncheck auto-pick and trigger its change event to enable the dropdowns
|
||||
autoPick.checked = false;
|
||||
autoPick.dispatchEvent(new Event('change'));
|
||||
|
||||
// Show the modal, ready for creation
|
||||
createModal.style.display = 'block';
|
||||
},
|
||||
|
||||
renderDashboard() {
|
||||
const grid = $('dashboardGrid');
|
||||
const ids = Object.keys(this.simulations);
|
||||
|
||||
if (ids.length === 0) {
|
||||
grid.innerHTML = '<p id="noInstancesMsg">No simulations running. Click \'Create Simulation\' to get started.</p>';
|
||||
} else {
|
||||
grid.innerHTML = ids.map(id => this.simulations[id].renderCard()).join('');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// === UI FORM SETUP (inside modal) ===
|
||||
const createModal = $('createModal');
|
||||
const detailModal = $('detailModal');
|
||||
const showCreateModalBtn = $('showCreateModalBtn');
|
||||
const launchBtn = $('launchBtn');
|
||||
const modeSelect = $('modeSelect');
|
||||
const autoPick = $('autoPick');
|
||||
const teamHome = $('teamHome'), teamAway = $('teamAway'), team1 = $('team1'), team2 = $('team2'), team3 = $('team3'), team4 = $('team4');
|
||||
const pickersSingle = document.querySelector('.picker-single');
|
||||
const pickersGroup = document.querySelector('.picker-group');
|
||||
|
||||
function setupForms() {
|
||||
function optionHTML(team){ return `<option value="${team}">${displayName(team)}</option>`; }
|
||||
function populateSelect(sel){ sel.innerHTML = teams.map(optionHTML).join(''); }
|
||||
[teamHome, teamAway, team1, team2, team3, team4].forEach(populateSelect);
|
||||
modeSelect.addEventListener('change', ()=>{
|
||||
if(modeSelect.value==='single'){ pickersSingle.style.display='flex'; pickersGroup.style.display='none'; }
|
||||
else { pickersSingle.style.display='none'; pickersGroup.style.display='flex'; }
|
||||
});
|
||||
modeSelect.dispatchEvent(new Event('change'));
|
||||
autoPick.addEventListener('change', ()=>{
|
||||
const disable = autoPick.checked;
|
||||
[teamHome, teamAway, team1, team2, team3, team4].forEach(s=> s.disabled = disable);
|
||||
});
|
||||
autoPick.dispatchEvent(new Event('change'));
|
||||
}
|
||||
setupForms();
|
||||
|
||||
// === MODAL HANDLING ===
|
||||
showCreateModalBtn.onclick = () => createModal.style.display = 'block';
|
||||
document.querySelectorAll('.close-btn').forEach(btn => {
|
||||
btn.onclick = () => $(btn.dataset.modal).style.display = 'none';
|
||||
});
|
||||
window.onclick = (event) => {
|
||||
if (event.target == createModal) createModal.style.display = "none";
|
||||
if (event.target == detailModal) detailModal.style.display = "none";
|
||||
};
|
||||
|
||||
// === LAUNCH & EVENT DELEGATION ===
|
||||
launchBtn.onclick = () => {
|
||||
const type = modeSelect.value;
|
||||
let selectedTeams = [];
|
||||
if (type === 'single') {
|
||||
selectedTeams = autoPick.checked ? uniqueRandomTeams(2) : [teamHome.value, teamAway.value];
|
||||
} else {
|
||||
selectedTeams = autoPick.checked ? uniqueRandomTeams(4) : [team1.value, team2.value, team3.value, team4.value];
|
||||
}
|
||||
SimulationManager.create(type, selectedTeams);
|
||||
createModal.style.display = 'none';
|
||||
};
|
||||
|
||||
$('dashboardGrid').addEventListener('click', (e) => {
|
||||
const card = e.target.closest('.instance-card');
|
||||
if (!card) return;
|
||||
const id = card.dataset.id;
|
||||
if (e.target.closest('.btn-start')) SimulationManager.start(id);
|
||||
if (e.target.closest('.btn-view')) SimulationManager.view(id);
|
||||
if (e.target.closest('.btn-export')) SimulationManager.export(id);
|
||||
// ADDED: Listener for the clone button
|
||||
if (e.target.closest('.btn-clone')) SimulationManager.clone(id);
|
||||
if (e.target.closest('.btn-delete')) SimulationManager.delete(id);
|
||||
});
|
||||
|
||||
// === REFACTORED CORE SIMULATION LOGIC (Unchanged) ===
|
||||
|
||||
function generateRecap(home, away, homeP, awayP, homeGoals, awayGoals, stats, homePoss, awayPoss){
|
||||
const winner = homeGoals>awayGoals ? displayName(home) : awayGoals>homeGoals ? displayName(away) : null;
|
||||
let verdict = winner ? `${winner} ${homeGoals>awayGoals?homeGoals:awayGoals}-${homeGoals>awayGoals?awayGoals:homeGoals} win.` : `It finishes level at ${homeGoals}-${awayGoals}.`;
|
||||
const xgCall = stats.home.xg>stats.away.xg ? `${displayName(home)} edge xG ${stats.home.xg.toFixed(2)} to ${stats.away.xg.toFixed(2)}.` : stats.away.xg>stats.home.xg ? `${displayName(away)} lead xG ${stats.away.xg.toFixed(2)} to ${stats.home.xg.toFixed(2)}.` : `xG dead even at ${stats.home.xg.toFixed(2)}.`;
|
||||
const style = homePoss>60? `${displayName(home)} control ${homePoss}% of the ball.`: awayPoss>60? `${displayName(away)} dominate ${awayPoss}% possession.`: `Possession fairly balanced (${homePoss}%–${awayPoss}%).`;
|
||||
const keepers = (stats.home.saves+stats.away.saves)>=6 ? `Keepers busy with ${stats.home.saves+stats.away.saves} combined saves.` : '';
|
||||
const discipline = (stats.home.yellows+stats.away.yellows)>=3 ? ` Card count: ${stats.home.yellows+stats.away.yellows}.` : '';
|
||||
return `<div class="recap" style="margin-top:10px"><h3 class="recap-title">Match Recap</h3><p>${verdict}</p><p>${xgCall} ${style} ${keepers}${discipline}</p><p>Setups: ${displayName(home)} — ${homeP.formation} (${TACTICS[homeP.tactic].label}) | ${displayName(away)} — ${awayP.formation} (${TACTICS[awayP.tactic].label}).</p></div>`;
|
||||
}
|
||||
|
||||
function simulateMatch(home, away, callbacks, speed=100) {
|
||||
return new Promise(resolve => {
|
||||
const homeP = profileFor(home); const awayP = profileFor(away);
|
||||
let minute=0, homeGoals=0, awayGoals=0;
|
||||
const stats = { home:{ shots:0, sot:0, xg:0, corners:0, fouls:0, yellows:0, offsides:0, saves:0, attacks:0 }, away:{ shots:0, sot:0, xg:0, corners:0, fouls:0, yellows:0, offsides:0, saves:0, attacks:0 } };
|
||||
|
||||
function renderScore() {
|
||||
callbacks.onScoreboard(`${displayName(home)} (${homeP.formation}) ${homeGoals} - ${awayGoals} ${displayName(away)} (${awayP.formation}) | ${fmt(minute)}'`);
|
||||
}
|
||||
|
||||
function renderStats() {
|
||||
const homePoss = Math.round( (stats.home.attacks * (homeP.tactic==='possession'?1.15:1)) / ((stats.home.attacks * (homeP.tactic==='possession'?1.15:1)) + (stats.away.attacks * (awayP.tactic==='possession'?1.15:1))) * 100 ) || 50;
|
||||
const awayPoss = 100 - homePoss;
|
||||
const table = `<div class="meta">Tactics: <strong>${displayName(home)}</strong> — ${TACTICS[homeP.tactic].label} | <strong>${displayName(away)}</strong> — ${TACTICS[awayP.tactic].label}</div><table class="stats-table"><thead><tr><th>Stat</th><th>${displayName(home)}</th><th>${displayName(away)}</th></tr></thead><tbody><tr><td>Shots (On Target)</td><td>${stats.home.shots} (${stats.home.sot})</td><td>${stats.away.shots} (${stats.away.sot})</td></tr><tr><td>xG (expected goals)</td><td>${stats.home.xg.toFixed(2)}</td><td>${stats.away.xg.toFixed(2)}</td></tr><tr><td>Corners</td><td>${stats.home.corners}</td><td>${stats.away.corners}</td></tr><tr><td>Fouls (Yellows)</td><td>${stats.home.fouls} (${stats.home.yellows})</td><td>${stats.away.fouls} (${stats.away.yellows})</td></tr><tr><td>Offsides</td><td>${stats.home.offsides}</td><td>${stats.away.offsides}</td></tr><tr><td>Saves</td><td>${stats.home.saves}</td><td>${stats.away.saves}</td></tr><tr><td>Possession %</td><td>${homePoss}%</td><td>${awayPoss}%</td></tr></tbody></table>`;
|
||||
const recap = generateRecap(home, away, homeP, awayP, homeGoals, awayGoals, stats, homePoss, awayPoss);
|
||||
callbacks.onStats(table + recap);
|
||||
}
|
||||
|
||||
let loop;
|
||||
function halftime() { clearInterval(loop); callbacks.onLog(`Halftime — ${displayName(home)} ${homeGoals}-${awayGoals} ${displayName(away)}`); setTimeout(()=>{ minute=45; loop=setInterval(tick, speed); }, 400); }
|
||||
function fulltime() { clearInterval(loop); callbacks.onLog(`Full time — ${displayName(home)} ${homeGoals}-${awayGoals} ${displayName(away)}`); renderStats(); resolve({home, away, homeGoals, awayGoals, stats, profiles:{home:homeP, away:awayP}}); }
|
||||
function chance(p){ return Math.random() < p; }
|
||||
|
||||
function tick() {
|
||||
minute++;
|
||||
const pressureBoost = (minute<15 || minute>75) ? 1.2 : 1.0;
|
||||
const homeBias = TACTICS[homeP.tactic].attackBias; const awayBias = TACTICS[awayP.tactic].attackBias;
|
||||
const homeAttacks = Math.random() * (homeBias + awayBias) < homeBias;
|
||||
const atk = homeAttacks ? 'home' : 'away', def = homeAttacks ? 'away' : 'home';
|
||||
const atkTeam = homeAttacks ? home : away, defTeam = homeAttacks ? away : home;
|
||||
const atkProf = homeAttacks ? homeP : awayP, defProf = homeAttacks ? awayP : homeP;
|
||||
const atkStats = stats[atk], defStats = stats[def];
|
||||
|
||||
if(chance(0.24 * pressureBoost)){
|
||||
atkStats.attacks++;
|
||||
const fastBreak = chance(TACTICS[atkProf.tactic].fastBreak);
|
||||
if(chance((fastBreak?0.75:0.55) * pressureBoost)){
|
||||
atkStats.shots++;
|
||||
let xg = fastBreak ? (0.20 + Math.random()*0.25) : (0.05 + Math.random()*0.22);
|
||||
xg *= TACTICS[atkProf.tactic].goalMult; xg /= TACTICS[defProf.tactic].blockMult;
|
||||
const onTarget = chance(0.52); if(onTarget) atkStats.sot++;
|
||||
const isGoal = onTarget && chance(xg);
|
||||
if(isGoal){ if(homeAttacks) homeGoals++; else awayGoals++; const finish = fastBreak?'cut‑back finish':'drilled low'; callbacks.onLog(`${fmt(minute)}' GOOOOAL — ${displayName(atkTeam)} (${finish}, xG ${xg.toFixed(2)})`); }
|
||||
else if(onTarget){ defStats.saves++; callbacks.onLog(`${fmt(minute)}' Big save by ${displayName(defTeam)}'s keeper!`); }
|
||||
else if(chance(0.25)){ callbacks.onLog(`${fmt(minute)}' ${displayName(atkTeam)} fire it just wide.`); }
|
||||
atkStats.xg += xg;
|
||||
}
|
||||
if(chance(0.05 * TACTICS[atkProf.tactic].attackBias)){ atkStats.corners++; callbacks.onLog(`${fmt(minute)}' Corner to ${displayName(atkTeam)}.`); }
|
||||
if(chance(0.035 + 0.02*TACTICS[atkProf.tactic].fastBreak)){ atkStats.offsides++; callbacks.onLog(`${fmt(minute)}' Flag up — ${displayName(atkTeam)} caught offside.`); }
|
||||
}
|
||||
if(chance(0.07 * TACTICS[atkProf.tactic].pressMult)){ defStats.fouls++; if(chance(0.22 * TACTICS[atkProf.tactic].pressMult)){ defStats.yellows++; callbacks.onLog(`${fmt(minute)}' Yellow card to ${displayName(defTeam)}.`); } }
|
||||
|
||||
renderScore();
|
||||
if(minute===45) return halftime(); if(minute>=90) return fulltime();
|
||||
}
|
||||
|
||||
callbacks.onLog(`Kickoff! ${displayName(home)} (${homeP.formation}, ${TACTICS[homeP.tactic].label}) vs ${displayName(away)} (${awayP.formation}, ${TACTICS[awayP.tactic].label})`);
|
||||
renderScore();
|
||||
loop = setInterval(tick, speed);
|
||||
});
|
||||
}
|
||||
|
||||
async function runSingle(home, away, callbacks) {
|
||||
return await simulateMatch(home, away, callbacks);
|
||||
}
|
||||
|
||||
async function runLeague4(four, callbacks) {
|
||||
function fixturesForFour([a,b,c,d]){ return [[a,b],[c,d],[a,c],[b,d],[a,d],[b,c]]; }
|
||||
function initStandings(teams){ const obj={}; teams.forEach(t=> obj[t]={ team:t, P:0,W:0,D:0,L:0,GF:0,GA:0,GD:0,PTS:0 }); return obj;}
|
||||
function renderStandingsTable(table, onUpdate){
|
||||
const rows = Object.values(table).sort((x,y)=> y.PTS-x.PTS || (y.GD-x.GD) || (y.GF-x.GF) || x.team.localeCompare(y.team))
|
||||
.map(r=>`<tr><td>${displayName(r.team)}</td><td>${r.P}</td><td>${r.W}</td><td>${r.D}</td><td>${r.L}</td><td>${r.GF}</td><td>${r.GA}</td><td>${r.GD}</td><td>${r.PTS}</td></tr>`).join('');
|
||||
const html = `<h3 style="margin:8px 0">League Table</h3><table class="standings-table"><thead><tr><th>Team</th><th>P</th><th>W</th><th>D</th><th>L</th><th>GF</th><th>GA</th><th>GD</th><th>Pts</th></tr></thead><tbody>${rows}</tbody></table>`;
|
||||
onUpdate(html, 'table');
|
||||
}
|
||||
|
||||
const fixtures = fixturesForFour(four);
|
||||
const table = initStandings(four);
|
||||
let i = 0;
|
||||
for (const [h, a] of fixtures) {
|
||||
i++;
|
||||
callbacks.onProgress(`Running League Match ${i}/${fixtures.length}...`);
|
||||
const res = await simulateMatch(h, a, callbacks);
|
||||
|
||||
table[h].P++; table[a].P++; table[h].GF+=res.homeGoals; table[h].GA+=res.awayGoals; table[a].GF+=res.awayGoals; table[a].GA+=res.homeGoals;
|
||||
table[h].GD = table[h].GF - table[h].GA; table[a].GD = table[a].GF - table[a].GA;
|
||||
if(res.homeGoals>res.awayGoals){ table[h].W++; table[a].L++; table[h].PTS+=3; }
|
||||
else if(res.homeGoals<res.awayGoals){ table[a].W++; table[h].L++; table[a].PTS+=3; }
|
||||
else { table[h].D++; table[a].D++; table[h].PTS+=1; table[a].PTS+=1; }
|
||||
|
||||
renderStandingsTable(table, callbacks.onCompetitionUpdate);
|
||||
callbacks.onHistoryUpdate(`${displayName(h)} ${res.homeGoals}-${res.awayGoals} ${displayName(a)}`);
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
|
||||
const sorted = Object.values(table).sort((x,y)=> y.PTS-x.PTS || (y.GD-x.GD) || (y.GF-x.GF) || x.team.localeCompare(y.team));
|
||||
const champ = sorted[0];
|
||||
callbacks.onHistoryUpdate(`<strong>Champion:</strong> ${displayName(champ.team)} with ${champ.PTS} pts.`);
|
||||
return { champion: champ, finalTable: table };
|
||||
}
|
||||
|
||||
async function runKnockout4(four, callbacks) {
|
||||
const bracketHTML = `<h3 style="margin:8px 0">Knockout Bracket</h3>`;
|
||||
callbacks.onCompetitionUpdate(bracketHTML, 'bracket');
|
||||
|
||||
const semis = [[four[0], four[3]], [four[1], four[2]]];
|
||||
const winners=[], losers=[];
|
||||
|
||||
for (let i=0; i<semis.length; i++) {
|
||||
const [h, a] = semis[i];
|
||||
callbacks.onProgress(`Running Semi-final ${i+1}/2...`);
|
||||
const res = await simulateMatch(h, a, callbacks);
|
||||
|
||||
let line = `${displayName(h)} ${res.homeGoals}-${res.awayGoals} ${displayName(a)}`;
|
||||
if (res.homeGoals === res.awayGoals) {
|
||||
const ps = penalties(h,a);
|
||||
line += ` (pens ${ps.home}-${ps.away})`;
|
||||
if (ps.winner === 'home') { winners.push(h); losers.push(a); }
|
||||
else { winners.push(a); losers.push(h); }
|
||||
} else {
|
||||
if (res.homeGoals > res.awayGoals) { winners.push(h); losers.push(a); }
|
||||
else { winners.push(a); losers.push(h); }
|
||||
}
|
||||
callbacks.onHistoryUpdate(`<strong>Semi ${i+1}:</strong> ${line}`);
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
}
|
||||
|
||||
callbacks.onProgress(`Running Final...`);
|
||||
const resF = await simulateMatch(winners[0], winners[1], callbacks);
|
||||
let lineF = `${displayName(winners[0])} ${resF.homeGoals}-${resF.awayGoals} ${displayName(winners[1])}`;
|
||||
let champTeam;
|
||||
if (resF.homeGoals === resF.awayGoals) {
|
||||
const ps = penalties(winners[0], winners[1]);
|
||||
lineF += ` (pens ${ps.home}-${ps.away})`;
|
||||
champTeam = ps.winner === 'home' ? winners[0] : winners[1];
|
||||
} else {
|
||||
champTeam = resF.homeGoals > resF.awayGoals ? winners[0] : winners[1];
|
||||
}
|
||||
callbacks.onHistoryUpdate(`<strong>Final:</strong> ${lineF}`);
|
||||
callbacks.onHistoryUpdate(`<strong>Champion:</strong> ${displayName(champTeam)} 🏆`);
|
||||
return { champion: champTeam };
|
||||
}
|
||||
|
||||
// Initial Render
|
||||
SimulationManager.renderDashboard();
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user