diff --git a/backend/src/TeleWave.Domain/Programming/Planning/JunctionFiller.cs b/backend/src/TeleWave.Domain/Programming/Planning/JunctionFiller.cs index c3ce861..d5da346 100644 --- a/backend/src/TeleWave.Domain/Programming/Planning/JunctionFiller.cs +++ b/backend/src/TeleWave.Domain/Programming/Planning/JunctionFiller.cs @@ -101,10 +101,12 @@ public static class JunctionFiller var (history, random, items) = run; - var eligible = junction - .Elements.Where(e => Passes(e, cursor, placement, history, random)) - .ToList(); - var chosen = ResolveChoices(eligible, random); + // Жребий бросается после схлопывания развилки, а не вместе с остальными условиями: из пяти + // заставок с шансом 60 % каждая бросала бы свой кубик, и «заставка в шести случаях из + // десяти» превращалось бы в «какая-нибудь заставка почти всегда». Развилка — это один + // выбор, значит и жребий у неё один. + var eligible = junction.Elements.Where(e => Passes(e, cursor, placement, history)).ToList(); + var chosen = ResolveChoices(eligible, random).Where(e => Rolls(e, random)).ToList(); var available = limit - cursor; if (junction.MaxTotal is { } cap && cap < available) @@ -134,13 +136,15 @@ public static class JunctionFiller return cursor; } - /// Проходит ли врезка по своим условиям: смена шоу, интервал, окно суток, жребий. + /// + /// Проходит ли врезка по своим условиям: смена шоу, интервал, окно суток, круглый час. Жребий + /// сюда не входит — он бросается после развилки (см. ). + /// private static bool Passes( PlanningJunctionElement element, DateTimeOffset cursor, JunctionPlacement placement, - JunctionHistory history, - IRandomSource random + JunctionHistory history ) { if (element.OnlyOnElementChange && !placement.ElementChanged) @@ -155,14 +159,19 @@ public static class JunctionFiller return false; } - if (!NearHour(element, cursor, placement.ChannelOffset)) - return false; - - // Жребий берётся из того же источника, что и весь прогон: он зависит от координат генерации, - // поэтому пересборка хвоста не перетасовывает врезки на каждое применение. - return element.Chance >= 100 || random.Next(100) < Math.Max(0, element.Chance); + return NearHour(element, cursor, placement.ChannelOffset); } + /// + /// Выпал ли жребий этой врезке. У развилки жребий один на всю метку: бросает уже выбранный + /// вариант, и его шанс — это шанс всей развилки. + /// + /// Источник — тот же, что и у всего прогона: он зависит от координат генерации, поэтому + /// пересборка хвоста не перетасовывает врезки на каждое применение. + /// + private static bool Rolls(PlanningJunctionElement element, IRandomSource random) => + element.Chance >= 100 || random.Next(100) < Math.Max(0, element.Chance); + /// /// Рядом ли момент с круглым часом. Сигнал точного времени — «пик-пик-пик» — тем и ценен, что /// звучит в :00, поэтому врезка с допуском ставится только в его окрестности, а не «когда-нибудь diff --git a/backend/tests/TeleWave.Domain.Tests/Programming/JunctionFillerTests.cs b/backend/tests/TeleWave.Domain.Tests/Programming/JunctionFillerTests.cs index ac010e4..d7f6612 100644 --- a/backend/tests/TeleWave.Domain.Tests/Programming/JunctionFillerTests.cs +++ b/backend/tests/TeleWave.Domain.Tests/Programming/JunctionFillerTests.cs @@ -21,6 +21,22 @@ public class JunctionFillerTests public int Next(int maxExclusive) => Math.Max(0, maxExclusive - 1); } + /// + /// Считает броски жребия. Жребий — единственный, кто спрашивает сотню, поэтому по этому числу + /// его и отличаем от выбора внутри развилки, который спрашивает сумму весов. + /// + private sealed class CountingRandom : IRandomSource + { + public int Rolls { get; private set; } + + public int Next(int maxExclusive) + { + if (maxExclusive == 100) + Rolls++; + return 0; + } + } + private static PlanningUnit Unit(double minutes, Guid? showId = null, int index = 0) => new(Guid.NewGuid(), TimeSpan.FromMinutes(minutes), showId ?? Guid.NewGuid(), index); @@ -318,6 +334,43 @@ public class JunctionFillerTests Assert.DoesNotContain(byLast.Items, i => i.Kind == PlannedItemKind.Ad); } + [Fact] + public void Choice_RollsChanceOnce_ForTheWholeFork() + { + // Три заставки в одной развилке — это один выбор, а не три независимых. Бросок жребия + // на каждую превратил бы «показать в 60 % случаев» в «показать хоть какую-то почти всегда». + var element = Series(2, 20, out _); + var fork = new PlanningJunction( + Guid.NewGuid(), + [ + Ads(1, chance: 60, choiceKey: "fork"), + Ads(1, chance: 60, choiceKey: "fork"), + Ads(1, chance: 60, choiceKey: "fork"), + ] + ); + + var random = new CountingRandom(); + Run(Slot(element, 2, between: fork), random); + + Assert.Equal(1, random.Rolls); + } + + [Fact] + public void Choice_TakesChanceOfThePickedVariant() + { + // Жребий бросает уже выбранный вариант: у него шанс ноль — значит развилка молчит целиком, + // а не подставляет вместо него соседа с сотней. + var element = Series(2, 20, out _); + var fork = new PlanningJunction( + Guid.NewGuid(), + [Ads(1, chance: 0, choiceKey: "fork"), Ads(1, chance: 100, choiceKey: "fork")] + ); + + var result = Run(Slot(element, 2, between: fork), new FirstAlways()); + + Assert.DoesNotContain(result.Items, i => i.Kind == PlannedItemKind.Ad); + } + [Fact] public void MaxTotal_CapsJunctionLength() { diff --git a/docs/tv-scheduler-architecture.md b/docs/tv-scheduler-architecture.md index 7874819..7df2d61 100644 --- a/docs/tv-scheduler-architecture.md +++ b/docs/tv-scheduler-architecture.md @@ -532,6 +532,14 @@ JunctionElement непрерывный отрезок позиций (иначе неясно, куда встаёт выбранная), обязательность и условия относятся к развилке целиком. +**Жребий бросается после выбора варианта, а не до.** Порядок здесь и есть смысл настройки: развилка +из пяти заставок с `chance = 60` — это «заставка в шести случаях из десяти, каждый раз разная». +Бросай кубик за каждую врезку отдельно, и получилось бы «какая-нибудь заставка почти всегда» +(1 − 0,4⁵ ≈ 99 %). Поэтому сначала отсекают условия (смена шоу, интервал, окно суток, круглый час), +потом развилка схлопывается в один вариант по весам, и уже он бросает единственный жребий своим +`chance`. Разные шансы у вариантов допустимы и означают ровно то, что написано: сыграет шанс +выбранного. + Оба жребия — `chance` и выбор внутри развилки — берутся из seed генерации (4.4), а не из живого `Random`. Иначе пересборка хвоста тасовала бы врезки на каждое применение, и диф из 6.6 показывал бы изменения там, где ничего не менялось. diff --git a/frontend/src/features/admin/channels/components/AirSchedule.tsx b/frontend/src/features/admin/channels/components/AirSchedule.tsx index 4b0740b..6441a99 100644 --- a/frontend/src/features/admin/channels/components/AirSchedule.tsx +++ b/frontend/src/features/admin/channels/components/AirSchedule.tsx @@ -54,6 +54,9 @@ export function AirSchedule({ const { t } = useTranslation() const [day, setDay] = useState(0) const [page, setPage] = useState(1) + // По умолчанию прошедшее скрыто: эфир смотрят, чтобы понять, что будет, а вчерашние страницы + // приходилось пролистывать каждый раз заново. + const [hidePast, setHidePast] = useState(true) const range = useMemo(() => { const from = dayStartUtc(day, utcOffsetMinutes, dayStartTime) @@ -65,9 +68,16 @@ export function AirSchedule({ queryFn: () => getSchedule(channelId, range.from, range.to), }) - const entries = data ?? [] - const totalPages = Math.max(1, Math.ceil(entries.length / PAGE_SIZE)) - const pageItems = entries.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE) + const entries = useMemo(() => data ?? [], [data]) + const visible = useMemo(() => { + if (!hidePast) return entries + // Идущая запись остаётся: то, что сейчас в эфире, — ещё не история. + const now = Date.now() + return entries.filter((entry) => new Date(entry.endsAtUtc).getTime() > now) + }, [entries, hidePast]) + + const totalPages = Math.max(1, Math.ceil(visible.length / PAGE_SIZE)) + const pageItems = visible.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE) return (
@@ -95,6 +105,18 @@ export function AirSchedule({ ) })} + +
{isLoading ? ( @@ -103,7 +125,7 @@ export function AirSchedule({ <>
- {t('admin.channels.airCount', { count: entries.length })} + {t('admin.channels.airCount', { count: visible.length })}
diff --git a/frontend/src/features/admin/junctions/components/JunctionElementDialog.tsx b/frontend/src/features/admin/junctions/components/JunctionElementDialog.tsx index 08c0a4d..5507e79 100644 --- a/frontend/src/features/admin/junctions/components/JunctionElementDialog.tsx +++ b/frontend/src/features/admin/junctions/components/JunctionElementDialog.tsx @@ -93,280 +93,290 @@ export function JunctionElementDialog({ return ( !open && onClose()}> - + {t('admin.junctions.element')} -
-
- - -
- - {isBumper ? ( - <> -
- - -
-
- - -

- {t('admin.junctions.bumperVariantHint')} -

-
- - ) : ( + {/* Две колонки: слева что играет, справа когда. Их читают вместе — «заставка при смене шоу + в шести случаях из десяти» — и в одну колонку это разъезжалось на два экрана прокрутки. */} +
+
- +
- )} - {!isBumper && ( - <> -
+ {isBumper ? ( + <>
- +
- - patch({ amountValue: Number(e.target.value) })} - /> -
-
-

{t('admin.junctions.amountHint')}

- - )} - - -

{t('admin.junctions.requiredHint')}

- - {/* Вес внутри развилки виден только когда врезка в развилке — иначе это лишнее поле. */} - {body.choiceKey && ( -
- - - patch({ choiceWeight: Math.max(0, Math.round(Number(e.target.value)) || 0) }) - } - /> -

- {t('admin.junctions.choiceWeightHint')} -

-
- )} - -
- {t('admin.junctions.conditions')} -
- - - -
-
- - - setConditions({ - chance: Math.min(100, Math.max(0, Math.round(Number(e.target.value)) || 0)), - }) - } - /> -
-
- - setConditions({ minMinutesBetween: Number(e.target.value) })} - /> -
-
-

{t('admin.junctions.chanceHint')}

- -
- -
- - setConditions({ - nearHourMinutes: Math.min( - 30, - Math.max(0, Math.round(Number(e.target.value)) || 0), - ), - }) - } - /> - - {t('admin.junctions.nearHourUnit')} - -
-

{t('admin.junctions.nearHourHint')}

-
- -
- -
- {DAYPARTS.map((daypart) => { - const active = conditions.dayparts?.includes(daypart) ?? false - return ( -
+ + ) : ( +
+ + +
+ )} + + {!isBumper && ( + <> +
+
+ + +
+
+ + patch({ amountValue: Number(e.target.value) })} + /> +
+
+

{t('admin.junctions.amountHint')}

+ + )} + + +

+ {t('admin.junctions.requiredHint')} +

+ + {/* Вес внутри развилки виден только когда врезка в развилке — иначе это лишнее поле. */} + {body.choiceKey && ( +
+ + + patch({ choiceWeight: Math.max(0, Math.round(Number(e.target.value)) || 0) }) + } + /> +

+ {t('admin.junctions.choiceWeightHint')} +

+
+ )}
-
- -
- - setConditions({ - timeWindow: e.target.value - ? { from: e.target.value, to: window?.to ?? '23:59' } - : null, - }) - } +
+
+ {t('admin.junctions.conditions')} +
+ + + +
+
+ + + setConditions({ + chance: Math.min(100, Math.max(0, Math.round(Number(e.target.value)) || 0)), + }) + } + /> +
+
+ + setConditions({ minMinutesBetween: Number(e.target.value) })} + /> +
+
+

{t('admin.junctions.chanceHint')}

+ +
+ +
+ + setConditions({ + nearHourMinutes: Math.min( + 30, + Math.max(0, Math.round(Number(e.target.value)) || 0), + ), + }) + } + /> + + {t('admin.junctions.nearHourUnit')} + +
+

{t('admin.junctions.nearHourHint')}

+
+ +
+ +
+ {DAYPARTS.map((daypart) => { + const active = conditions.dayparts?.includes(daypart) ?? false + return ( + + ) + })} +
+

{t('admin.junctions.daypartsHint')}

+
+ +
+ +
+ + setConditions({ + timeWindow: e.target.value + ? { from: e.target.value, to: window?.to ?? '23:59' } + : null, + }) + } + /> + + + setConditions({ + timeWindow: e.target.value + ? { from: window?.from ?? '00:00', to: e.target.value } + : null, + }) + } + /> + {window && ( + + )} +
+

{t('admin.junctions.timeWindowHint')}

-

{t('admin.junctions.timeWindowHint')}

diff --git a/frontend/src/shared/lib/locales/en.ts b/frontend/src/shared/lib/locales/en.ts index dfc4be1..db3c0cc 100644 --- a/frontend/src/shared/lib/locales/en.ts +++ b/frontend/src/shared/lib/locales/en.ts @@ -782,6 +782,7 @@ export const en = { noSchedule: 'Schedule not built yet', airToday: 'Today', airCount: 'Entries for the day: {{count}}', + airHidePast: 'Hide past entries', }, junctions: { title: 'Junctions', @@ -830,7 +831,7 @@ export const en = { onlyOnChange: 'Only on show change', chance: 'Chance, %', chanceHint: - 'Chance and interval are per break; the roll comes from the generation seed, so rebuilds do not reshuffle breaks.', + 'The interval is per break. A fork rolls once for all its options: the picked variant rolls, and its chance is the chance of the whole fork. The roll comes from the generation seed, so rebuilds do not reshuffle breaks.', minInterval: 'No more often than once per, min', dayparts: 'Dayparts', daypartsHint: 'Nothing selected — the break plays in any daypart.', diff --git a/frontend/src/shared/lib/locales/ru.ts b/frontend/src/shared/lib/locales/ru.ts index 756cc51..9c7eb5d 100644 --- a/frontend/src/shared/lib/locales/ru.ts +++ b/frontend/src/shared/lib/locales/ru.ts @@ -777,6 +777,7 @@ export const ru = { noSchedule: 'Расписание ещё не построено', airToday: 'Сегодня', airCount: 'Записей за сутки: {{count}}', + airHidePast: 'Скрывать прошедшее', }, junctions: { title: 'Стыки', @@ -825,7 +826,7 @@ export const ru = { onlyOnChange: 'Только при смене шоу', chance: 'Вероятность, %', chanceHint: - 'Вероятность и интервал считаются по этой врезке отдельно; жребий берётся из seed генерации, поэтому пересборка не тасует врезки.', + 'Интервал считается по этой врезке отдельно. У развилки жребий один на всех: бросает выбранный вариант, и его вероятность — вероятность всей развилки. Жребий берётся из seed генерации, поэтому пересборка не тасует врезки.', minInterval: 'Не чаще, чем раз в, мин', dayparts: 'Дейпарты', daypartsHint: 'Ничего не выбрано — врезка идёт в любых дейпартах.',