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", () => {

View File

@@ -7682,3 +7682,99 @@ body.hockey-navigation-open .runtime-viewport.has-hockey-pbp { gap: 14px !import
.hockey-prepared-layout { grid-template-columns:250px minmax(0,1fr) 250px; }
.hockey-prepared-field { grid-template-columns:1fr; gap:4px; }
}
/* BUILD119 — roster role counters + manual operational score. */
.hpd-roster-count-breakdown{
flex:0 0 auto;
min-width:0 !important;
display:flex;
align-items:center;
gap:4px;
padding:4px 6px !important;
white-space:nowrap;
}
.hpd-roster-count-breakdown b{
min-width:20px;
color:#dce8f6;
font-size:10px;
font-weight:950;
text-align:center;
}
.hpd-roster-count-breakdown span{
min-width:19px;
padding-left:4px;
border-left:1px solid #294059;
color:#7f96b1;
font-size:8px;
font-weight:900;
letter-spacing:0;
text-align:center;
}
.quick-score-control{
flex:0 0 auto;
height:34px;
display:flex;
align-items:center;
gap:3px;
padding:0 5px;
color:#dce8f5;
border:1px solid #30465e;
border-radius:8px;
background:linear-gradient(180deg,#132238,#0e1a2a);
box-shadow:0 3px 10px rgba(0,0,0,.18);
}
.quick-score-control.is-manual{
border-color:#8d6c36;
background:linear-gradient(180deg,#2d2619,#1d1a13);
}
.quick-score-team{
max-width:68px;
overflow:hidden;
color:#8fa5be;
font-size:8px;
font-weight:900;
text-overflow:ellipsis;
white-space:nowrap;
}
.quick-score-team.side-away{text-align:right}
.quick-score-control button{
width:25px;
height:25px;
padding:0;
color:#cbd9e8;
border:1px solid #314963;
border-radius:6px;
background:#0c1828;
font-size:14px;
font-weight:950;
line-height:1;
}
.quick-score-control button:hover{color:#fff;border-color:#55718f;background:#14253a}
.quick-score-control strong{
min-width:18px;
color:#fff;
font-family:"Roboto Mono","Cascadia Mono",Consolas,monospace;
font-size:15px;
font-variant-numeric:tabular-nums;
font-weight:950;
text-align:center;
}
.quick-score-control i{color:#647d99;font-style:normal;font-size:13px;font-weight:950}
.quick-score-control .quick-score-sync{width:27px;color:#87a0bc;font-size:13px}
.quick-score-control small{
min-width:29px;
padding:3px 4px;
color:#6fe0c5;
border-radius:5px;
background:rgba(72,223,189,.08);
font-size:7px;
font-weight:950;
letter-spacing:.04em;
text-align:center;
}
.quick-score-control.is-manual small{color:#e3bc72;background:rgba(227,188,114,.09)}
@media (max-width:920px){
.quick-score-team{display:none}
.quick-score-control{padding-inline:4px}
}