BUILD119 — ручной счёт + SQL + составы

This commit is contained in:
2026-08-26 13:54:47 +03:00
parent c5b25c23e2
commit fb422d5bf8
10 changed files with 544 additions and 16 deletions

View File

@@ -2377,7 +2377,7 @@ function startCustomTooltips() {
}
function normalizeSequenceStep(step = {}, index = 0) {
const allowedTypes = new Set(["timer_command", "hockey_penalties_command", "vmix_command", "hockey_vmix_timers_start", "delay", "dispatch_event"]);
const allowedTypes = new Set(["timer_command", "hockey_penalties_command", "hockey_score_command", "vmix_command", "hockey_vmix_timers_start", "delay", "dispatch_event"]);
const allowedConditions = new Set(sequenceConditions.map(([value]) => value));
const type = allowedTypes.has(String(step.type || "")) ? String(step.type) : "vmix_command";
const condition = allowedConditions.has(String(step.condition || "")) ? String(step.condition) : "always";
@@ -2392,6 +2392,7 @@ function startCustomTooltips() {
timer_command: String(step.timer_command || "start"),
timer_value: step.timer_value ?? "",
penalty_command: String(step.penalty_command || "start"),
score_command: ["home_plus", "home_minus", "away_plus", "away_minus", "sync"].includes(String(step.score_command || "")) ? String(step.score_command) : "home_plus",
function: String(step.function || ""),
input: String(step.input || ""),
value: step.value ?? "",
@@ -5386,6 +5387,9 @@ function startCustomTooltips() {
entries.forEach(({ component, event }) => controlHockeyPenalty(component, event.id, step.penalty_command || "start", step.timer_value || ""));
return { ok: true, applied: entries.length };
}
case "hockey_score_command": {
return await hockeyScoreCommand(step.score_command || "home_plus", { refreshMapping: true, announce: false });
}
case "vmix_command": {
const useAlternate = Boolean(step.use_scoreboard_alternate && hockeyScoreboardIsLive() && step.scoreboard_alternate_input);
const inputTemplate = useAlternate ? step.scoreboard_alternate_input : step.input;
@@ -6810,6 +6814,7 @@ function openTimerQuickEditor(focusActionId = "") {
number: get("numberField", ""),
name: get("nameField", `Игрок ${index + 1}`),
position: get("positionField", ""),
role: String(row?.role || ""),
raw: clone(row)
};
}
@@ -6833,6 +6838,26 @@ function openTimerQuickEditor(focusActionId = "") {
.slice(0, limit);
}
function hockeyRosterCounts(players) {
const counts = { total: Array.isArray(players) ? players.length : 0, forwards: 0, defenders: 0, goalkeepers: 0, other: 0 };
(Array.isArray(players) ? players : []).forEach((player) => {
const role = String(player?.role || player?.raw?.role || "").trim().toLowerCase();
const position = String(player?.position || player?.raw?.position || player?.raw?.position_ru || player?.raw?.position_en || "")
.trim().toLowerCase().replaceAll("ё", "е");
if (role === "goalkeeper" || /(^|\s)(вр|врат|goal|gk)(\s|$)/i.test(position)) counts.goalkeepers += 1;
else if (role === "defender" || /защ|defen|\bd\b/i.test(position)) counts.defenders += 1;
else if (role === "forward" || /нап|forward|wing|center|centre|\bf\b/i.test(position)) counts.forwards += 1;
else counts.other += 1;
});
return counts;
}
function hockeyRosterCountMarkup(players) {
const counts = hockeyRosterCounts(players);
const tooltip = `Всего: ${counts.total} · Нападающие: ${counts.forwards} · Защитники: ${counts.defenders} · Вратари: ${counts.goalkeepers}${counts.other ? ` · Не определено: ${counts.other}` : ""}`;
return `<em class="hpd-roster-count-breakdown" data-tooltip="${escapeHtml(tooltip)}" aria-label="${escapeHtml(tooltip)}"><b>${counts.total}</b><span>Н${counts.forwards}</span><span>З${counts.defenders}</span><span>В${counts.goalkeepers}</span></em>`;
}
function hockeyTeamName(component, side) {
const path = side === "home" ? component.props?.homeTeamPath : component.props?.awayTeamPath;
return formatValue(getByPath(state.data, path), side === "home" ? "Хозяева" : "Гости");
@@ -7709,7 +7734,7 @@ function readAnyHockeyDragData(event) {
<strong>${escapeHtml(hockeyTeamName(component, side))}</strong>
${getByPath(state.data, `hockey.selected_game.${side}.coach`) ? `<small class="hpd-roster-coach">Тренер: ${escapeHtml(getByPath(state.data, `hockey.selected_game.${side}.coach`))}</small>` : ""}
</div>
<em>${players.length}</em>
${hockeyRosterCountMarkup(players)}
`;
panel.appendChild(header);
@@ -8885,6 +8910,16 @@ async function hockeyRefreshQuickPanelMapping() {
} catch (_) { return false; }
}
async function hockeyRefreshScoreMapping() {
const deviceId = currentRuntimeVmixDeviceId();
if (!deviceId) return false;
try {
const response = await fetch(`/api/hockey/agents/devices/${encodeURIComponent(deviceId)}/apply-mapping?only_changed=true&source_code=score`, { method: "POST", cache: "no-store", credentials: "same-origin" });
if (!response.ok) return false;
return true;
} catch (_) { return false; }
}
async function hockeySetMatchValues(patch, { refreshMapping = true } = {}) {
const gameId = hockeyTimerSelectedGameId();
if (!gameId) throw new Error("Сначала выберите матч");
@@ -8901,6 +8936,84 @@ async function hockeySetMatchValues(patch, { refreshMapping = true } = {}) {
return payload;
}
function hockeyScoreControl() {
const score = getByPath(state.data, "hockey.game_control.score");
const game = getByPath(state.data, "hockey.selected_game") || {};
const fallbackHome = Number(game?.home?.score ?? game?.home_score ?? 0) || 0;
const fallbackAway = Number(game?.away?.score ?? game?.away_score ?? 0) || 0;
const source = score && typeof score === "object" ? score : {};
return {
home: Math.max(0, Number(source.home ?? fallbackHome) || 0),
away: Math.max(0, Number(source.away ?? fallbackAway) || 0),
external_home: Math.max(0, Number(source.external_home ?? fallbackHome) || 0),
external_away: Math.max(0, Number(source.external_away ?? fallbackAway) || 0),
manual: Boolean(source.manual),
differs: Boolean(source.differs),
source: String(source.source || "external"),
};
}
function hockeyApplyScoreToRuntime(score = null, { render = false } = {}) {
const current = score && typeof score === "object" ? score : hockeyScoreControl();
window.UIBuilderRuntime?.patchData?.({
hockey: {
home: { score: Number(current.home || 0) },
away: { score: Number(current.away || 0) },
},
}, { render });
}
async function hockeyScoreCommand(command, { refreshMapping = true, announce = true } = {}) {
const gameId = hockeyTimerSelectedGameId();
if (!gameId) throw new Error("Сначала выберите матч");
const allowed = new Set(["home_plus", "home_minus", "away_plus", "away_minus", "sync"]);
command = String(command || "").trim();
if (!allowed.has(command)) throw new Error("Неизвестная команда счёта");
const language = hockeyGameControlLanguage();
const before = hockeyScoreControl();
// Before entering manual mode (and before an explicit resync), resolve only the
// configured score SQL source so the operation starts from the freshest baseline.
if (refreshMapping && (!before.manual || command === "sync")) await hockeyRefreshScoreMapping();
const payload = await hockeyGameControlRequest(`/games/${encodeURIComponent(gameId)}/control/score`, {
method: "PUT",
body: JSON.stringify({ command, language }),
});
hockeyStoreGameControl(gameId, payload, { render: false, dispatch: true });
hockeyApplyScoreToRuntime(payload?.score, { render: false });
renderHockeyQuickCommandDock();
if (refreshMapping) await hockeyRefreshScoreMapping();
if (announce) {
const score = payload?.score || {};
const label = command === "sync" ? "Счёт синхронизирован" : "Счёт изменён";
toast(`${label}: ${Number(score.home || 0)}:${Number(score.away || 0)}`);
}
return payload;
}
function hockeyScoreDockMarkup() {
const game = getByPath(state.data, "hockey.selected_game") || {};
if (!String(game?.external_id || game?.id || "").trim()) return "";
const score = hockeyScoreControl();
const homeName = String(game?.home?.name || "HOME");
const awayName = String(game?.away?.name || "AWAY");
const tooltip = score.manual
? `Ручной LIVE: ${score.home}:${score.away} · SQL/API: ${score.external_home}:${score.external_away}. Нажмите ↻, чтобы снова принять внешний счёт.`
: `LIVE следует SQL/API: ${score.external_home}:${score.external_away}. Первое +/ переведёт счёт в ручной режим.`;
return `<div class="quick-score-control ${score.manual ? "is-manual" : "is-external"}" data-tooltip="${escapeHtml(tooltip)}">
<span class="quick-score-team" title="${escapeHtml(homeName)}">${escapeHtml(homeName)}</span>
<button type="button" data-hockey-score-command="home_minus" aria-label="HOME минус гол"></button>
<strong>${score.home}</strong>
<button type="button" data-hockey-score-command="home_plus" aria-label="HOME плюс гол">+</button>
<i>:</i>
<button type="button" data-hockey-score-command="away_minus" aria-label="AWAY минус гол"></button>
<strong>${score.away}</strong>
<button type="button" data-hockey-score-command="away_plus" aria-label="AWAY плюс гол">+</button>
<span class="quick-score-team side-away" title="${escapeHtml(awayName)}">${escapeHtml(awayName)}</span>
<button type="button" class="quick-score-sync" data-hockey-score-command="sync" aria-label="Синхронизировать счёт с SQL/API">↻</button>
<small>${score.manual ? "РУЧН" : "SQL"}</small>
</div>`;
}
function quickPanelSelectorMarkup(selector) {
const current = quickPanelSelectorValue(selector);
const tooltip = selector.description || selector.label;
@@ -8968,7 +9081,7 @@ function renderHockeyQuickCommandDock() {
<div class="quick-command-tab-scroll">${tabMarkup || `<span class="quick-command-no-tabs">Создайте вкладку для операторских кнопок</span>`}</div>
<button type="button" class="quick-command-settings" data-quick-command-settings data-tooltip="Настроить вкладки и кнопки">⚙</button>
</div>
<div class="quick-command-dock-buttons">${buttonMarkup || standaloneMarkup ? `${buttonMarkup}${standaloneMarkup}` : `<span class="quick-command-empty">Во вкладке пока нет кнопок</span>`}</div>
<div class="quick-command-dock-buttons">${hockeyScoreDockMarkup()}${buttonMarkup || standaloneMarkup ? `${buttonMarkup}${standaloneMarkup}` : `<span class="quick-command-empty">Во вкладке пока нет кнопок</span>`}</div>
`;
el.runtimeButtonDock.querySelectorAll("[data-quick-command-tab]").forEach((tab) => tab.addEventListener("click", () => {
state.quickPanelActiveTab = String(tab.dataset.quickCommandTab || "");
@@ -8996,6 +9109,19 @@ function renderHockeyQuickCommandDock() {
}
});
});
el.runtimeButtonDock.querySelectorAll("[data-hockey-score-command]").forEach((control) => control.addEventListener("click", async (event) => {
event.preventDefault();
event.stopPropagation();
if (control.dataset.busy === "1") return;
control.dataset.busy = "1";
try {
await hockeyScoreCommand(control.dataset.hockeyScoreCommand, { refreshMapping: true, announce: true });
} catch (error) {
toast(`Счёт: ${String(error?.message || error)}`, true);
} finally {
delete control.dataset.busy;
}
}));
el.runtimeButtonDock.querySelectorAll("[data-quick-command-button]").forEach((control) => control.addEventListener("click", async (event) => {
event.preventDefault();
const button = buttons.find((item) => item.id === control.dataset.quickCommandButton);
@@ -11846,8 +11972,14 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
const nextPeriod = String(payload?.current_period || "");
hockeyApplyTimerRules(payload);
state.hockeyGameControl[String(gameId)] = payload;
const scorePatch = payload?.score && typeof payload.score === "object"
? {
home: { score: Math.max(0, Number(payload.score.home || 0)) },
away: { score: Math.max(0, Number(payload.score.away || 0)) },
}
: {};
window.UIBuilderRuntime?.patchData?.(
{ hockey: { game_control: payload } },
{ hockey: { game_control: payload, ...scorePatch } },
{ render }
);
hockeyBackupPlayerSelectionValues(gameId, payload?.values || {});
@@ -12269,8 +12401,13 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
state.hockeyTimerSaveTimer = null;
}
state.hockeyTimerDirty = false;
const payload = await hockeyLoadGameControl(gameId, { force: true, rerender: false });
let payload = await hockeyLoadGameControl(gameId, { force: true, rerender: false });
hockeyApplySavedTimers(gameId, payload?.timers || null);
// BUILD119: on a real match switch resolve the configured score SQL keys once
// so a mid-game join immediately starts from the external score baseline.
if (!payload?.score?.manual && await hockeyRefreshScoreMapping()) {
payload = await hockeyLoadGameControl(gameId, { force: true, rerender: false }) || payload;
}
state.hockeyTimerDirty = false;
return payload;
} finally {
@@ -13871,6 +14008,10 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
switch (step.type) {
case "timer_command": return `Веб-таймер · ${step.timer_command || "start"}`;
case "hockey_penalties_command": return `Удаления в вебе · ${step.penalty_command || "start"}`;
case "hockey_score_command": {
const labels = { home_plus: "HOME +1", home_minus: "HOME 1", away_plus: "AWAY +1", away_minus: "AWAY 1", sync: "синхронизация SQL/API" };
return `Счёт · ${labels[step.score_command] || step.score_command || "HOME +1"}`;
}
case "vmix_command": return `vMix · ${step.function || "Function"}${step.input ? ` · Input ${step.input}` : ""}`;
case "hockey_vmix_timers_start": return `Хоккей · таймеры · ${step.hockey_timer_command === "pause" ? "пауза" : step.hockey_timer_command === "start" ? "старт" : step.hockey_timer_command === "resume" ? "продолжить" : "старт / пауза"}`;
case "delay": return `Задержка · ${Number(step.milliseconds) || 0} мс`;
@@ -13946,6 +14087,7 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
${triggerSelectOptions([
["timer_command","Веб: управление таймером"],
["hockey_penalties_command","Веб: все текущие удаления"],
["hockey_score_command","Хоккей: ручной счёт +/- / синхронизация"],
["vmix_command","vMix: произвольная команда"],
["hockey_vmix_timers_start","Хоккей: синхронный старт / пауза таймеров"],
["delay","Задержка"],
@@ -13976,6 +14118,16 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
<label>Дашборд<select data-step-field="target_action_id">${hockeyPenaltyBoardOptions(step.target_action_id)}</select></label>
<label>Команда<select data-step-field="penalty_command">${triggerSelectOptions([["start","Запустить все"],["pause","Пауза всем"],["reset","Сбросить все"]], step.penalty_command)}</select></label>
</div><p class="shortcut-step-note">Если Action ID не выбран, команда применяется ко всем текущим удалениям во всех хоккейных дашбордах.</p>`;
} else if (step.type === "hockey_score_command") {
body.innerHTML = `<div class="shortcut-step-grid">
<label>Команда счёта<select data-step-field="score_command">${triggerSelectOptions([
["home_plus","HOME +1 гол"],
["home_minus","HOME 1 гол"],
["away_plus","AWAY +1 гол"],
["away_minus","AWAY 1 гол"],
["sync","Синхронизировать с SQL/API"],
], step.score_command)}</select></label>
</div><p class="shortcut-step-note">Первое ручное +/ фиксирует оперативный LIVE-счёт для текущего матча. Последующие обновления API его не перезаписывают. «Синхронизировать» берёт последний счёт из базы/API и снова включает внешний режим.</p>`;
} else if (step.type === "vmix_command") {
const functionKnown = !step.function || VMIX_FUNCTIONS.includes(String(step.function));
body.innerHTML = `<div class="shortcut-step-grid vmix-command-grid">
@@ -14792,6 +14944,11 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
hockeyActivateGameTimers(gameId);
} else if (!state.hockeyGameControl[gameId]) {
hockeyLoadGameControl(gameId, { force: false, rerender: false });
} else if (state.hockeyGameControl[gameId]?.score?.manual) {
// Stat2TV polling still refreshes the selected-game object every second.
// In manual score mode immediately restore the operator-authoritative LIVE
// score so a late API response cannot visually roll the score backwards.
hockeyApplyScoreToRuntime(state.hockeyGameControl[gameId].score, { render: false });
}
});
window.addEventListener("beforeunload", () => {