web version initial implementation

This commit is contained in:
markmental 2026-02-11 11:57:27 -05:00
commit 20568d2b3e
8 changed files with 2747 additions and 698 deletions

446
data.js
View file

@ -1,80 +1,376 @@
// =======================================================
// SoccerCloud Simulator Data File
// Contains all team, flag, and profile information.
// =======================================================
const teams = [
// J-League
"Kashima Antlers","Urawa Red Diamonds","Gamba Osaka","Cerezo Osaka","Kawasaki Frontale",
"Yokohama F. Marinos","Nagoya Grampus","Shimizu S-Pulse","Sanfrecce Hiroshima","Consadole Sapporo",
"Ventforet Kofu","Tokyo Verdy","JEF United Chiba",
// Euro clubs
"Arsenal","FC Barcelona","Real Madrid","Manchester City","Manchester United","Liverpool",
"Bayern Munich","Borussia Dortmund","Paris Saint-Germain","Juventus","Inter","AC Milan",
"Ajax","Benfica","Porto","Celtic"
];
const TEAM_FLAGS = {
// Japan
"Kashima Antlers":"🇯🇵","Urawa Red Diamonds":"🇯🇵","Gamba Osaka":"🇯🇵","Cerezo Osaka":"🇯🇵","Kawasaki Frontale":"🇯🇵",
"Yokohama F. Marinos":"🇯🇵","Nagoya Grampus":"🇯🇵","Shimizu S-Pulse":"🇯🇵","Sanfrecce Hiroshima":"🇯🇵","Consadole Sapporo":"🇯🇵",
"Ventforet Kofu":"🇯🇵","Tokyo Verdy":"🇯🇵", "JEF United Chiba":"🇯🇵",
// UK
"Arsenal":"🇬🇧","Manchester City":"🇬🇧","Manchester United":"🇬🇧","Liverpool":"🇬🇧","Celtic":"🇬🇧",
// Spain
"FC Barcelona":"🇪🇸","Real Madrid":"🇪🇸",
// Germany
"Bayern Munich":"🇩🇪","Borussia Dortmund":"🇩🇪",
// France
"Paris Saint-Germain":"🇫🇷",
// Italy
"Juventus":"🇮🇹","Inter":"🇮🇹","AC Milan":"🇮🇹",
// Netherlands
"Ajax":"🇳🇱",
// Portugal
"Benfica":"🇵🇹","Porto":"🇵🇹"
const state = {
teams: [],
simulations: [],
selectedDetailId: null,
pollHandle: null,
};
const FORMATIONS = ["4-4-2","4-3-3","4-2-3-1","3-5-2","5-4-1"];
const $ = (id) => document.getElementById(id);
const TACTICS = {
counter: { label:"Counter", attackBias:1.10, goalMult:1.08, fastBreak:0.25, foulMult:1.00, blockMult:1.00, pressMult:0.95 },
possession: { label:"Possession", attackBias:1.00, goalMult:0.95, fastBreak:0.10, foulMult:0.90, blockMult:1.00, pressMult:0.90 },
high_press: { label:"High Press", attackBias:1.15, goalMult:1.00, fastBreak:0.20, foulMult:1.20, blockMult:0.95, pressMult:1.20 },
low_block: { label:"Low Block", attackBias:0.92, goalMult:0.92, fastBreak:0.12, foulMult:0.95, blockMult:1.15, pressMult:0.85 },
};
function setStatus(message) {
$("statusText").textContent = message;
}
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" }
};
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 updateCardElement(card, sim) {
card.querySelector('[data-role="title"]').textContent = sim.title;
card.querySelector('[data-role="idline"]').textContent = `sim-${sim.id} | ${sim.mode} | seed=${sim.seed}`;
card.querySelector('[data-role="progress"]').textContent = sim.progress;
card.querySelector('[data-role="scoreboard"]').textContent = sim.scoreboard;
card.querySelector('[data-role="outcome"]').textContent = sim.outcome;
const pill = card.querySelector('[data-role="pill"]');
pill.className = `pill ${sim.status}`;
pill.textContent = sim.status;
if (card.dataset.status !== sim.status) {
card.dataset.status = sim.status;
card.querySelector('[data-role="actions"]').innerHTML = cardActions(sim);
}
}
function renderDashboard() {
const root = $("dashboard");
if (!root) return;
if (state.simulations.length === 0) {
root.innerHTML = `<div class="empty" id="dashboardEmpty">No simulation instances yet. Create one to start the web simulation flow.</div>`;
return;
}
const existingCards = new Map(
[...root.querySelectorAll("article.card")].map((card) => [Number(card.dataset.id), card]),
);
const activeIds = new Set(state.simulations.map((sim) => sim.id));
const fragment = document.createDocumentFragment();
for (const sim of state.simulations) {
let card = existingCards.get(sim.id);
if (!card) {
card = createCardElement(sim);
}
updateCardElement(card, sim);
fragment.appendChild(card);
}
for (const [id, card] of existingCards.entries()) {
if (!activeIds.has(id) && card.parentElement === root) {
card.remove();
}
}
root.replaceChildren(fragment);
}
function renderStats() {
const total = state.simulations.length;
const running = state.simulations.filter((s) => s.status === "running").length;
const completed = state.simulations.filter((s) => s.status === "completed").length;
$("statInstances").textContent = String(total);
$("statRunning").textContent = String(running);
$("statCompleted").textContent = String(completed);
$("statTeams").textContent = String(state.teams.length);
}
async function refreshSimulations() {
state.simulations = await request("/api/simulations");
renderStats();
renderDashboard();
if (state.selectedDetailId !== null) {
const exists = state.simulations.some((s) => s.id === state.selectedDetailId);
if (!exists) {
state.selectedDetailId = null;
closeModal("detailModal");
return;
}
await loadDetail(state.selectedDetailId);
}
}
function textOrPlaceholder(lines, fallback) {
if (!Array.isArray(lines) || lines.length === 0) {
return fallback;
}
return lines.join("\n");
}
async function loadDetail(id) {
const detail = await request(`/api/simulations/${id}`);
state.selectedDetailId = id;
$("detailTitle").textContent = `sim-${detail.id} - ${detail.title}`;
$("detailScoreboard").textContent = `${detail.scoreboard} | ${detail.outcome}`;
$("detailLogs").textContent = textOrPlaceholder(detail.logs, "No events yet.");
$("detailStats").textContent = textOrPlaceholder(detail.stats_lines, "No stats available yet.");
$("detailCompetition").textContent = textOrPlaceholder(detail.competition_lines, "No standings/bracket available yet.");
$("detailHistory").textContent = textOrPlaceholder(detail.history_lines, "No history recorded yet.");
}
async function createSimulation() {
try {
const payload = getCreatePayload();
const created = await request("/api/simulations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
closeModal("createModal");
setStatus(`Created sim-${created.id}`);
await refreshSimulations();
} catch (error) {
setStatus(`Create failed: ${error.message}`);
}
}
async function startSimulation(id) {
try {
await request(`/api/simulations/${id}/start`, { method: "POST" });
setStatus(`Started sim-${id}`);
await refreshSimulations();
} catch (error) {
setStatus(`Start failed: ${error.message}`);
}
}
async function cloneSimulation(id) {
try {
const created = await request(`/api/simulations/${id}/clone`, { method: "POST" });
setStatus(`Cloned sim-${id} to sim-${created.id}`);
await refreshSimulations();
} catch (error) {
setStatus(`Clone failed: ${error.message}`);
}
}
async function deleteSimulation(id) {
try {
await request(`/api/simulations/${id}`, { method: "DELETE" });
if (state.selectedDetailId === id) {
state.selectedDetailId = null;
closeModal("detailModal");
}
setStatus(`Deleted sim-${id}`);
await refreshSimulations();
} catch (error) {
setStatus(`Delete failed: ${error.message}`);
}
}
function exportSimulation(id) {
window.location.href = `/api/simulations/${id}/export.csv`;
setStatus(`Exporting sim-${id} CSV...`);
}
function bindEvents() {
$("openCreateBtn").addEventListener("click", () => openModal("createModal"));
$("createBtn").addEventListener("click", createSimulation);
$("modeSelect").addEventListener("change", renderTeamSelectors);
$("autoFill").addEventListener("change", renderTeamSelectors);
document.querySelectorAll("[data-close]").forEach((button) => {
button.addEventListener("click", () => {
closeModal(button.dataset.close);
if (button.dataset.close === "detailModal") {
state.selectedDetailId = null;
}
});
});
["createModal", "detailModal"].forEach((id) => {
const modal = $(id);
modal.addEventListener("click", (event) => {
if (event.target === modal) {
closeModal(id);
if (id === "detailModal") {
state.selectedDetailId = null;
}
}
});
});
$("dashboard").addEventListener("click", async (event) => {
const button = event.target.closest("button[data-action]");
if (!button) return;
const id = Number(button.dataset.id);
const action = button.dataset.action;
if (action === "start") return startSimulation(id);
if (action === "clone") return cloneSimulation(id);
if (action === "delete") return deleteSimulation(id);
if (action === "export") return exportSimulation(id);
if (action === "view") {
try {
await loadDetail(id);
openModal("detailModal");
} catch (error) {
setStatus(`Failed to load detail: ${error.message}`);
}
}
});
}
async function boot() {
bindEvents();
try {
setStatus("Loading teams and simulations...");
state.teams = await request("/api/teams");
renderTeamSelectors();
await refreshSimulations();
setStatus("Connected to SoccerCloud backend on port 9009.");
} catch (error) {
setStatus(`Startup failed: ${error.message}`);
return;
}
if (state.pollHandle) {
clearInterval(state.pollHandle);
}
state.pollHandle = setInterval(async () => {
try {
await refreshSimulations();
} catch (error) {
setStatus(`Polling issue: ${error.message}`);
}
}, 500);
}
boot();