Refactor JunctionFiller logic to improve eligibility and rolling mechanics
Updated the JunctionFiller class to adjust the eligibility criteria for junction elements, ensuring that the roll for chance is determined after the selection of a variant. Enhanced the Passes method to exclude the rolling logic, which now occurs in a separate Rolls method. This change clarifies the flow of decision-making in junctions and ensures that the chance of the selected variant is accurately represented. Additionally, introduced new tests to validate the updated rolling behavior and its impact on junction processing.
This commit is contained in:
@@ -101,10 +101,12 @@ public static class JunctionFiller
|
|||||||
|
|
||||||
var (history, random, items) = run;
|
var (history, random, items) = run;
|
||||||
|
|
||||||
var eligible = junction
|
// Жребий бросается после схлопывания развилки, а не вместе с остальными условиями: из пяти
|
||||||
.Elements.Where(e => Passes(e, cursor, placement, history, random))
|
// заставок с шансом 60 % каждая бросала бы свой кубик, и «заставка в шести случаях из
|
||||||
.ToList();
|
// десяти» превращалось бы в «какая-нибудь заставка почти всегда». Развилка — это один
|
||||||
var chosen = ResolveChoices(eligible, random);
|
// выбор, значит и жребий у неё один.
|
||||||
|
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;
|
var available = limit - cursor;
|
||||||
if (junction.MaxTotal is { } cap && cap < available)
|
if (junction.MaxTotal is { } cap && cap < available)
|
||||||
@@ -134,13 +136,15 @@ public static class JunctionFiller
|
|||||||
return cursor;
|
return cursor;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Проходит ли врезка по своим условиям: смена шоу, интервал, окно суток, жребий.</summary>
|
/// <summary>
|
||||||
|
/// Проходит ли врезка по своим условиям: смена шоу, интервал, окно суток, круглый час. Жребий
|
||||||
|
/// сюда не входит — он бросается после развилки (см. <see cref="Rolls"/>).
|
||||||
|
/// </summary>
|
||||||
private static bool Passes(
|
private static bool Passes(
|
||||||
PlanningJunctionElement element,
|
PlanningJunctionElement element,
|
||||||
DateTimeOffset cursor,
|
DateTimeOffset cursor,
|
||||||
JunctionPlacement placement,
|
JunctionPlacement placement,
|
||||||
JunctionHistory history,
|
JunctionHistory history
|
||||||
IRandomSource random
|
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
if (element.OnlyOnElementChange && !placement.ElementChanged)
|
if (element.OnlyOnElementChange && !placement.ElementChanged)
|
||||||
@@ -155,14 +159,19 @@ public static class JunctionFiller
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!NearHour(element, cursor, placement.ChannelOffset))
|
return NearHour(element, cursor, placement.ChannelOffset);
|
||||||
return false;
|
|
||||||
|
|
||||||
// Жребий берётся из того же источника, что и весь прогон: он зависит от координат генерации,
|
|
||||||
// поэтому пересборка хвоста не перетасовывает врезки на каждое применение.
|
|
||||||
return element.Chance >= 100 || random.Next(100) < Math.Max(0, element.Chance);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Выпал ли жребий этой врезке. У развилки жребий один на всю метку: бросает уже выбранный
|
||||||
|
/// вариант, и его шанс — это шанс всей развилки.
|
||||||
|
///
|
||||||
|
/// Источник — тот же, что и у всего прогона: он зависит от координат генерации, поэтому
|
||||||
|
/// пересборка хвоста не перетасовывает врезки на каждое применение.
|
||||||
|
/// </summary>
|
||||||
|
private static bool Rolls(PlanningJunctionElement element, IRandomSource random) =>
|
||||||
|
element.Chance >= 100 || random.Next(100) < Math.Max(0, element.Chance);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Рядом ли момент с круглым часом. Сигнал точного времени — «пик-пик-пик» — тем и ценен, что
|
/// Рядом ли момент с круглым часом. Сигнал точного времени — «пик-пик-пик» — тем и ценен, что
|
||||||
/// звучит в :00, поэтому врезка с допуском ставится только в его окрестности, а не «когда-нибудь
|
/// звучит в :00, поэтому врезка с допуском ставится только в его окрестности, а не «когда-нибудь
|
||||||
|
|||||||
@@ -21,6 +21,22 @@ public class JunctionFillerTests
|
|||||||
public int Next(int maxExclusive) => Math.Max(0, maxExclusive - 1);
|
public int Next(int maxExclusive) => Math.Max(0, maxExclusive - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Считает броски жребия. Жребий — единственный, кто спрашивает сотню, поэтому по этому числу
|
||||||
|
/// его и отличаем от выбора внутри развилки, который спрашивает сумму весов.
|
||||||
|
/// </summary>
|
||||||
|
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) =>
|
private static PlanningUnit Unit(double minutes, Guid? showId = null, int index = 0) =>
|
||||||
new(Guid.NewGuid(), TimeSpan.FromMinutes(minutes), showId ?? Guid.NewGuid(), index);
|
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);
|
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]
|
[Fact]
|
||||||
public void MaxTotal_CapsJunctionLength()
|
public void MaxTotal_CapsJunctionLength()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -532,6 +532,14 @@ JunctionElement
|
|||||||
непрерывный отрезок позиций (иначе неясно, куда встаёт выбранная), обязательность и условия
|
непрерывный отрезок позиций (иначе неясно, куда встаёт выбранная), обязательность и условия
|
||||||
относятся к развилке целиком.
|
относятся к развилке целиком.
|
||||||
|
|
||||||
|
**Жребий бросается после выбора варианта, а не до.** Порядок здесь и есть смысл настройки: развилка
|
||||||
|
из пяти заставок с `chance = 60` — это «заставка в шести случаях из десяти, каждый раз разная».
|
||||||
|
Бросай кубик за каждую врезку отдельно, и получилось бы «какая-нибудь заставка почти всегда»
|
||||||
|
(1 − 0,4⁵ ≈ 99 %). Поэтому сначала отсекают условия (смена шоу, интервал, окно суток, круглый час),
|
||||||
|
потом развилка схлопывается в один вариант по весам, и уже он бросает единственный жребий своим
|
||||||
|
`chance`. Разные шансы у вариантов допустимы и означают ровно то, что написано: сыграет шанс
|
||||||
|
выбранного.
|
||||||
|
|
||||||
Оба жребия — `chance` и выбор внутри развилки — берутся из seed генерации (4.4), а не из живого
|
Оба жребия — `chance` и выбор внутри развилки — берутся из seed генерации (4.4), а не из живого
|
||||||
`Random`. Иначе пересборка хвоста тасовала бы врезки на каждое применение, и диф из 6.6 показывал бы
|
`Random`. Иначе пересборка хвоста тасовала бы врезки на каждое применение, и диф из 6.6 показывал бы
|
||||||
изменения там, где ничего не менялось.
|
изменения там, где ничего не менялось.
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ export function AirSchedule({
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const [day, setDay] = useState(0)
|
const [day, setDay] = useState(0)
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
|
// По умолчанию прошедшее скрыто: эфир смотрят, чтобы понять, что будет, а вчерашние страницы
|
||||||
|
// приходилось пролистывать каждый раз заново.
|
||||||
|
const [hidePast, setHidePast] = useState(true)
|
||||||
|
|
||||||
const range = useMemo(() => {
|
const range = useMemo(() => {
|
||||||
const from = dayStartUtc(day, utcOffsetMinutes, dayStartTime)
|
const from = dayStartUtc(day, utcOffsetMinutes, dayStartTime)
|
||||||
@@ -65,9 +68,16 @@ export function AirSchedule({
|
|||||||
queryFn: () => getSchedule(channelId, range.from, range.to),
|
queryFn: () => getSchedule(channelId, range.from, range.to),
|
||||||
})
|
})
|
||||||
|
|
||||||
const entries = data ?? []
|
const entries = useMemo(() => data ?? [], [data])
|
||||||
const totalPages = Math.max(1, Math.ceil(entries.length / PAGE_SIZE))
|
const visible = useMemo(() => {
|
||||||
const pageItems = entries.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)
|
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 (
|
return (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
@@ -95,6 +105,18 @@ export function AirSchedule({
|
|||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
<label className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={hidePast}
|
||||||
|
onChange={(e) => {
|
||||||
|
setHidePast(e.target.checked)
|
||||||
|
setPage(1)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{t('admin.channels.airHidePast')}
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -103,7 +125,7 @@ export function AirSchedule({
|
|||||||
<>
|
<>
|
||||||
<SchedulePreview entries={pageItems} onShowTrace={onShowTrace} />
|
<SchedulePreview entries={pageItems} onShowTrace={onShowTrace} />
|
||||||
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
<div className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
|
||||||
<span>{t('admin.channels.airCount', { count: entries.length })}</span>
|
<span>{t('admin.channels.airCount', { count: visible.length })}</span>
|
||||||
<Pager page={page} totalPages={totalPages} onChange={setPage} />
|
<Pager page={page} totalPages={totalPages} onChange={setPage} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -93,280 +93,290 @@ export function JunctionElementDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||||
<DialogContent>
|
<DialogContent className="flex max-h-[90vh] w-[calc(100%-2rem)] max-w-3xl flex-col">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t('admin.junctions.element')}</DialogTitle>
|
<DialogTitle>{t('admin.junctions.element')}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex max-h-[70vh] flex-col gap-3 overflow-y-auto text-sm">
|
{/* Две колонки: слева что играет, справа когда. Их читают вместе — «заставка при смене шоу
|
||||||
<div className="flex flex-col gap-1.5">
|
в шести случаях из десяти» — и в одну колонку это разъезжалось на два экрана прокрутки. */}
|
||||||
<Label>{t('admin.junctions.kind')}</Label>
|
<div className="grid flex-1 grid-cols-1 items-start gap-x-6 gap-y-3 overflow-y-auto text-sm sm:grid-cols-2">
|
||||||
<select
|
<div className="flex flex-col gap-3">
|
||||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
|
||||||
value={body.kind}
|
|
||||||
onChange={(e) => patch({ kind: e.target.value as JunctionElementKind })}
|
|
||||||
>
|
|
||||||
{KINDS.map((kind) => (
|
|
||||||
<option key={kind} value={kind}>
|
|
||||||
{t(`admin.junctions.kinds.${kind}`)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isBumper ? (
|
|
||||||
<>
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label>{t('admin.junctions.bumperTemplate')}</Label>
|
|
||||||
<select
|
|
||||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
|
||||||
value={body.bumperTemplateId ?? ''}
|
|
||||||
onChange={(e) =>
|
|
||||||
patch({ bumperTemplateId: e.target.value || null, bumperVariantId: null })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="">{t('admin.junctions.pickBumper')}</option>
|
|
||||||
{(bumpers ?? []).map((bumper) => (
|
|
||||||
<option key={bumper.id} value={bumper.id}>
|
|
||||||
{bumper.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label>{t('admin.junctions.bumperVariant')}</Label>
|
|
||||||
<select
|
|
||||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
|
||||||
value={body.bumperVariantId ?? ''}
|
|
||||||
onChange={(e) => patch({ bumperVariantId: e.target.value || null })}
|
|
||||||
>
|
|
||||||
<option value="">{t('admin.junctions.bumperVariantAuto')}</option>
|
|
||||||
{(template?.variants ?? []).map((variant) => (
|
|
||||||
<option key={variant.id} value={variant.id}>
|
|
||||||
{variant.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t('admin.junctions.bumperVariantHint')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label>{t('admin.junctions.group')}</Label>
|
<Label>{t('admin.junctions.kind')}</Label>
|
||||||
<select
|
<select
|
||||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||||
value={body.groupId ?? ''}
|
value={body.kind}
|
||||||
onChange={(e) => patch({ groupId: e.target.value || null })}
|
onChange={(e) => patch({ kind: e.target.value as JunctionElementKind })}
|
||||||
>
|
>
|
||||||
<option value="">{t('admin.junctions.pickGroup')}</option>
|
{KINDS.map((kind) => (
|
||||||
{(groups ?? []).map((group) => (
|
<option key={kind} value={kind}>
|
||||||
<option key={group.id} value={group.id}>
|
{t(`admin.junctions.kinds.${kind}`)}
|
||||||
{group.name} · {group.itemCount}
|
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{!isBumper && (
|
{isBumper ? (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label>{t('admin.junctions.amountMode')}</Label>
|
<Label>{t('admin.junctions.bumperTemplate')}</Label>
|
||||||
<select
|
<select
|
||||||
className="h-9 rounded-md border border-border bg-transparent px-2"
|
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||||
value={body.amountMode}
|
value={body.bumperTemplateId ?? ''}
|
||||||
onChange={(e) => patch({ amountMode: e.target.value as JunctionAmountMode })}
|
onChange={(e) =>
|
||||||
|
patch({ bumperTemplateId: e.target.value || null, bumperVariantId: null })
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{AMOUNT_MODES.map((mode) => (
|
<option value="">{t('admin.junctions.pickBumper')}</option>
|
||||||
<option key={mode} value={mode}>
|
{(bumpers ?? []).map((bumper) => (
|
||||||
{t(`admin.junctions.amountModes.${mode}`)}
|
<option key={bumper.id} value={bumper.id}>
|
||||||
|
{bumper.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label>
|
<Label>{t('admin.junctions.bumperVariant')}</Label>
|
||||||
{body.amountMode === 'Count'
|
<select
|
||||||
? t('admin.junctions.count')
|
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||||
: t('admin.junctions.minutes')}
|
value={body.bumperVariantId ?? ''}
|
||||||
</Label>
|
onChange={(e) => patch({ bumperVariantId: e.target.value || null })}
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
value={body.amountValue}
|
|
||||||
onChange={(e) => patch({ amountValue: Number(e.target.value) })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">{t('admin.junctions.amountHint')}</p>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<label className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={body.isRequired}
|
|
||||||
onChange={(e) => patch({ isRequired: e.target.checked })}
|
|
||||||
/>
|
|
||||||
{t('admin.junctions.required')}
|
|
||||||
</label>
|
|
||||||
<p className="-mt-2 text-xs text-muted-foreground">{t('admin.junctions.requiredHint')}</p>
|
|
||||||
|
|
||||||
{/* Вес внутри развилки виден только когда врезка в развилке — иначе это лишнее поле. */}
|
|
||||||
{body.choiceKey && (
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label>{t('admin.junctions.choiceWeight')}</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
max={1000}
|
|
||||||
className="w-32"
|
|
||||||
value={body.choiceWeight}
|
|
||||||
onChange={(e) =>
|
|
||||||
patch({ choiceWeight: Math.max(0, Math.round(Number(e.target.value)) || 0) })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t('admin.junctions.choiceWeightHint')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="border-t border-border pt-3 text-xs uppercase tracking-wide text-muted-foreground">
|
|
||||||
{t('admin.junctions.conditions')}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={conditions.onlyOnElementChange}
|
|
||||||
onChange={(e) => setConditions({ onlyOnElementChange: e.target.checked })}
|
|
||||||
/>
|
|
||||||
{t('admin.junctions.onlyOnChange')}
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label>{t('admin.junctions.chance')}</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
value={conditions.chance}
|
|
||||||
onChange={(e) =>
|
|
||||||
setConditions({
|
|
||||||
chance: Math.min(100, Math.max(0, Math.round(Number(e.target.value)) || 0)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label>{t('admin.junctions.minInterval')}</Label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={0}
|
|
||||||
value={conditions.minMinutesBetween}
|
|
||||||
onChange={(e) => setConditions({ minMinutesBetween: Number(e.target.value) })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="-mt-1 text-xs text-muted-foreground">{t('admin.junctions.chanceHint')}</p>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label>{t('admin.junctions.nearHour')}</Label>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
className="w-32"
|
|
||||||
min={0}
|
|
||||||
max={30}
|
|
||||||
value={conditions.nearHourMinutes}
|
|
||||||
onChange={(e) =>
|
|
||||||
setConditions({
|
|
||||||
nearHourMinutes: Math.min(
|
|
||||||
30,
|
|
||||||
Math.max(0, Math.round(Number(e.target.value)) || 0),
|
|
||||||
),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{t('admin.junctions.nearHourUnit')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground">{t('admin.junctions.nearHourHint')}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label>{t('admin.junctions.dayparts')}</Label>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{DAYPARTS.map((daypart) => {
|
|
||||||
const active = conditions.dayparts?.includes(daypart) ?? false
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={daypart}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
const current = conditions.dayparts ?? []
|
|
||||||
const next = active
|
|
||||||
? current.filter((d) => d !== daypart)
|
|
||||||
: [...current, daypart]
|
|
||||||
setConditions({ dayparts: next.length === 0 ? null : next })
|
|
||||||
}}
|
|
||||||
className={`rounded border px-2 py-1 text-xs ${
|
|
||||||
active ? 'border-primary text-primary' : 'border-border text-muted-foreground'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{t(`admin.channels.dayparts.${daypart}`)}
|
<option value="">{t('admin.junctions.bumperVariantAuto')}</option>
|
||||||
</button>
|
{(template?.variants ?? []).map((variant) => (
|
||||||
)
|
<option key={variant.id} value={variant.id}>
|
||||||
})}
|
{variant.name}
|
||||||
</div>
|
</option>
|
||||||
<p className="text-xs text-muted-foreground">{t('admin.junctions.daypartsHint')}</p>
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('admin.junctions.bumperVariantHint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.junctions.group')}</Label>
|
||||||
|
<select
|
||||||
|
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||||
|
value={body.groupId ?? ''}
|
||||||
|
onChange={(e) => patch({ groupId: e.target.value || null })}
|
||||||
|
>
|
||||||
|
<option value="">{t('admin.junctions.pickGroup')}</option>
|
||||||
|
{(groups ?? []).map((group) => (
|
||||||
|
<option key={group.id} value={group.id}>
|
||||||
|
{group.name} · {group.itemCount}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isBumper && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.junctions.amountMode')}</Label>
|
||||||
|
<select
|
||||||
|
className="h-9 rounded-md border border-border bg-transparent px-2"
|
||||||
|
value={body.amountMode}
|
||||||
|
onChange={(e) => patch({ amountMode: e.target.value as JunctionAmountMode })}
|
||||||
|
>
|
||||||
|
{AMOUNT_MODES.map((mode) => (
|
||||||
|
<option key={mode} value={mode}>
|
||||||
|
{t(`admin.junctions.amountModes.${mode}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>
|
||||||
|
{body.amountMode === 'Count'
|
||||||
|
? t('admin.junctions.count')
|
||||||
|
: t('admin.junctions.minutes')}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={body.amountValue}
|
||||||
|
onChange={(e) => patch({ amountValue: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('admin.junctions.amountHint')}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={body.isRequired}
|
||||||
|
onChange={(e) => patch({ isRequired: e.target.checked })}
|
||||||
|
/>
|
||||||
|
{t('admin.junctions.required')}
|
||||||
|
</label>
|
||||||
|
<p className="-mt-2 text-xs text-muted-foreground">
|
||||||
|
{t('admin.junctions.requiredHint')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Вес внутри развилки виден только когда врезка в развилке — иначе это лишнее поле. */}
|
||||||
|
{body.choiceKey && (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.junctions.choiceWeight')}</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={1000}
|
||||||
|
className="w-32"
|
||||||
|
value={body.choiceWeight}
|
||||||
|
onChange={(e) =>
|
||||||
|
patch({ choiceWeight: Math.max(0, Math.round(Number(e.target.value)) || 0) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('admin.junctions.choiceWeightHint')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-3">
|
||||||
<Label>{t('admin.junctions.timeWindow')}</Label>
|
<div className="border-t border-border pt-3 text-xs uppercase tracking-wide text-muted-foreground">
|
||||||
<div className="flex items-center gap-2">
|
{t('admin.junctions.conditions')}
|
||||||
<Input
|
</div>
|
||||||
type="time"
|
|
||||||
className="w-32"
|
<label className="flex items-center gap-2">
|
||||||
value={window?.from.slice(0, 5) ?? ''}
|
<input
|
||||||
onChange={(e) =>
|
type="checkbox"
|
||||||
setConditions({
|
checked={conditions.onlyOnElementChange}
|
||||||
timeWindow: e.target.value
|
onChange={(e) => setConditions({ onlyOnElementChange: e.target.checked })}
|
||||||
? { from: e.target.value, to: window?.to ?? '23:59' }
|
|
||||||
: null,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground">—</span>
|
{t('admin.junctions.onlyOnChange')}
|
||||||
<Input
|
</label>
|
||||||
type="time"
|
|
||||||
className="w-32"
|
<div className="grid grid-cols-2 gap-2">
|
||||||
value={window?.to.slice(0, 5) ?? ''}
|
<div className="flex flex-col gap-1.5">
|
||||||
onChange={(e) =>
|
<Label>{t('admin.junctions.chance')}</Label>
|
||||||
setConditions({
|
<Input
|
||||||
timeWindow: e.target.value
|
type="number"
|
||||||
? { from: window?.from ?? '00:00', to: e.target.value }
|
min={0}
|
||||||
: null,
|
max={100}
|
||||||
})
|
value={conditions.chance}
|
||||||
}
|
onChange={(e) =>
|
||||||
/>
|
setConditions({
|
||||||
{window && (
|
chance: Math.min(100, Math.max(0, Math.round(Number(e.target.value)) || 0)),
|
||||||
<Button
|
})
|
||||||
size="sm"
|
}
|
||||||
variant="ghost"
|
/>
|
||||||
onClick={() => setConditions({ timeWindow: null })}
|
</div>
|
||||||
>
|
<div className="flex flex-col gap-1.5">
|
||||||
{t('admin.junctions.clearWindow')}
|
<Label>{t('admin.junctions.minInterval')}</Label>
|
||||||
</Button>
|
<Input
|
||||||
)}
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={conditions.minMinutesBetween}
|
||||||
|
onChange={(e) => setConditions({ minMinutesBetween: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="-mt-1 text-xs text-muted-foreground">{t('admin.junctions.chanceHint')}</p>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.junctions.nearHour')}</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
className="w-32"
|
||||||
|
min={0}
|
||||||
|
max={30}
|
||||||
|
value={conditions.nearHourMinutes}
|
||||||
|
onChange={(e) =>
|
||||||
|
setConditions({
|
||||||
|
nearHourMinutes: Math.min(
|
||||||
|
30,
|
||||||
|
Math.max(0, Math.round(Number(e.target.value)) || 0),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{t('admin.junctions.nearHourUnit')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('admin.junctions.nearHourHint')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.junctions.dayparts')}</Label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{DAYPARTS.map((daypart) => {
|
||||||
|
const active = conditions.dayparts?.includes(daypart) ?? false
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={daypart}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const current = conditions.dayparts ?? []
|
||||||
|
const next = active
|
||||||
|
? current.filter((d) => d !== daypart)
|
||||||
|
: [...current, daypart]
|
||||||
|
setConditions({ dayparts: next.length === 0 ? null : next })
|
||||||
|
}}
|
||||||
|
className={`rounded border px-2 py-1 text-xs ${
|
||||||
|
active
|
||||||
|
? 'border-primary text-primary'
|
||||||
|
: 'border-border text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t(`admin.channels.dayparts.${daypart}`)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('admin.junctions.daypartsHint')}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label>{t('admin.junctions.timeWindow')}</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
type="time"
|
||||||
|
className="w-32"
|
||||||
|
value={window?.from.slice(0, 5) ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setConditions({
|
||||||
|
timeWindow: e.target.value
|
||||||
|
? { from: e.target.value, to: window?.to ?? '23:59' }
|
||||||
|
: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
<Input
|
||||||
|
type="time"
|
||||||
|
className="w-32"
|
||||||
|
value={window?.to.slice(0, 5) ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setConditions({
|
||||||
|
timeWindow: e.target.value
|
||||||
|
? { from: window?.from ?? '00:00', to: e.target.value }
|
||||||
|
: null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{window && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setConditions({ timeWindow: null })}
|
||||||
|
>
|
||||||
|
{t('admin.junctions.clearWindow')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('admin.junctions.timeWindowHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">{t('admin.junctions.timeWindowHint')}</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -782,6 +782,7 @@ export const en = {
|
|||||||
noSchedule: 'Schedule not built yet',
|
noSchedule: 'Schedule not built yet',
|
||||||
airToday: 'Today',
|
airToday: 'Today',
|
||||||
airCount: 'Entries for the day: {{count}}',
|
airCount: 'Entries for the day: {{count}}',
|
||||||
|
airHidePast: 'Hide past entries',
|
||||||
},
|
},
|
||||||
junctions: {
|
junctions: {
|
||||||
title: 'Junctions',
|
title: 'Junctions',
|
||||||
@@ -830,7 +831,7 @@ export const en = {
|
|||||||
onlyOnChange: 'Only on show change',
|
onlyOnChange: 'Only on show change',
|
||||||
chance: 'Chance, %',
|
chance: 'Chance, %',
|
||||||
chanceHint:
|
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',
|
minInterval: 'No more often than once per, min',
|
||||||
dayparts: 'Dayparts',
|
dayparts: 'Dayparts',
|
||||||
daypartsHint: 'Nothing selected — the break plays in any daypart.',
|
daypartsHint: 'Nothing selected — the break plays in any daypart.',
|
||||||
|
|||||||
@@ -777,6 +777,7 @@ export const ru = {
|
|||||||
noSchedule: 'Расписание ещё не построено',
|
noSchedule: 'Расписание ещё не построено',
|
||||||
airToday: 'Сегодня',
|
airToday: 'Сегодня',
|
||||||
airCount: 'Записей за сутки: {{count}}',
|
airCount: 'Записей за сутки: {{count}}',
|
||||||
|
airHidePast: 'Скрывать прошедшее',
|
||||||
},
|
},
|
||||||
junctions: {
|
junctions: {
|
||||||
title: 'Стыки',
|
title: 'Стыки',
|
||||||
@@ -825,7 +826,7 @@ export const ru = {
|
|||||||
onlyOnChange: 'Только при смене шоу',
|
onlyOnChange: 'Только при смене шоу',
|
||||||
chance: 'Вероятность, %',
|
chance: 'Вероятность, %',
|
||||||
chanceHint:
|
chanceHint:
|
||||||
'Вероятность и интервал считаются по этой врезке отдельно; жребий берётся из seed генерации, поэтому пересборка не тасует врезки.',
|
'Интервал считается по этой врезке отдельно. У развилки жребий один на всех: бросает выбранный вариант, и его вероятность — вероятность всей развилки. Жребий берётся из seed генерации, поэтому пересборка не тасует врезки.',
|
||||||
minInterval: 'Не чаще, чем раз в, мин',
|
minInterval: 'Не чаще, чем раз в, мин',
|
||||||
dayparts: 'Дейпарты',
|
dayparts: 'Дейпарты',
|
||||||
daypartsHint: 'Ничего не выбрано — врезка идёт в любых дейпартах.',
|
daypartsHint: 'Ничего не выбрано — врезка идёт в любых дейпартах.',
|
||||||
|
|||||||
Reference in New Issue
Block a user