BUILD119 — ручной счёт + SQL + составы
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user