Drive-In event ideas: occasions, not shared tags (rules 3)
pauljd/davidson-ci: Davidson CI / quality (push) Failing after 2m2s
pauljd/davidson-ci: Davidson CI / ai-review (push) Failing after 1m37s

The first run on the box proposed Batman: The Dark Knight Returns Part 2
before Part 1 and Kill Bill Vol. 2 without Vol. 1 in a Late Show to nearly
4am, so the rules tighten: a later part never without its earlier parts,
story order for short franchises from their first film, running-time limits
per format (Late Show 4 h, Friday double 4.5 h, Saturday triple 6 h), a
triple only with three films. No sport events (Ring X's) or concerts. A
person's night needs four read films, English-language films of 7.0 or more
and one on the acclaim or trending lists; animation is themed by franchise
or studio. Other bills: English-language by their read countries unless a
studio makes the occasion, a 6.8 average and no film under 6.2. Adult means
an adult rating or horror not rated for families. Looks six weeks ahead.
Ideas shown under the old rules are withdrawn and kept in the history as
such. Replay on the box's files: Toho Monster Night and an Ant-Man double.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-05 18:09:20 +00:00
co-authored by Claude Opus 5.5
parent e29c16268f
commit a4d3b3ea11
6 changed files with 191 additions and 37 deletions
@@ -121,15 +121,19 @@ internal class ChannelScheduler(private val store: ScheduleStore, private val cl
val zone = java.time.ZoneId.systemDefault()
val today = java.time.Instant.ofEpochMilli(now).atZone(zone).toLocalDate()
val file = store.eventIdeasFile(); val historyFile = store.eventIdeaHistoryFile()
if (runCatching { JSONObject(file.readText()).optString("day") }.getOrNull() == today.toString()) return
if (runCatching { JSONObject(file.readText()).let { it.optString("day") == today.toString() && it.optInt("rules") == EVENT_IDEA_RULES } }.getOrNull() == true) return
EventIdeaHistory.withdrawOld(historyFile)
val weekStart = today.with(java.time.DayOfWeek.MONDAY).atStartOfDay(zone).toInstant().toEpochMilli()
val shownThisWeek = EventIdeaHistory.load(historyFile).count { it.optLong("shownMs") >= weekStart }
fun live(h: JSONObject) = h.optLong("shownMs") >= weekStart && !h.optString("decision").startsWith("withdrawn")
val shownThisWeek = EventIdeaHistory.load(historyFile).count(::live)
val evidenceFile = store.evidenceFile()
val lines = if (evidenceFile.exists()) evidenceFile.readLines().mapNotNull { runCatching { JSONObject(it) }.getOrNull() }.filter { it.optString("type") == "Movie" }.associateBy { it.optString("id") }.toMutableMap() else mutableMapOf()
val notable = listMembers(store.collectionsFile(), TrendingLists)
fun enrich(f: IdeaFilm): IdeaFilm = lines[f.id]?.let { e ->
fun names(key: String) = e.optJSONArray(key)?.let { a -> (0 until a.length()).map { a.optString(it) } }.orEmpty()
IdeaFilm(f.id, f.title, f.year, f.imdb, f.runtimeMs, f.rating, f.genres, f.studios, names("directors"), names("leads"), if (e.isNull("community")) null else e.optDouble("community").takeIf { !it.isNaN() })
} ?: f
IdeaFilm(f.id, f.title, f.year, f.imdb, f.runtimeMs, f.rating, f.genres, f.studios, names("directors"), names("leads"), if (e.isNull("community")) null else e.optDouble("community").takeIf { !it.isNaN() },
names("countries"), f.id in notable)
} ?: IdeaFilm(f.id, f.title, f.year, f.imdb, f.runtimeMs, f.rating, f.genres, f.studios, notable = f.id in notable)
val collections = if (store.collectionsFile().exists()) store.collectionsFile().readLines().mapNotNull { runCatching { JSONObject(it) }.getOrNull() }.map { o ->
o.optString("name") to o.optJSONArray("members")?.let { a -> (0 until a.length()).map { a.getString(it) } }.orEmpty() } else emptyList()
val studios = EventStudios.associateWith { catalogue.filmsByStudio(it.first) }
@@ -160,8 +164,8 @@ internal class ChannelScheduler(private val store: ScheduleStore, private val cl
val ideas = weeklyEventIdeas(verifiedSeeds, films, unavailable, { format, taken -> ideaEvening(format, today.plusDays(1), drivePlanEnd, taken, format.films * 2 * 60 * 60_000L, zone) },
EventIdeaHistory.dismissed(historyFile), limit = (EVENT_IDEAS_PER_WEEK - shownThisWeek).coerceAtLeast(0))
EventIdeaHistory.record(historyFile, ideas, now)
val thisWeek = EventIdeaHistory.load(historyFile).filter { it.optLong("shownMs") >= weekStart }
runCatching { file.writeText(JSONObject().put("day", today.toString()).put("ideas", JSONArray(thisWeek.map { h ->
val thisWeek = EventIdeaHistory.load(historyFile).filter(::live)
runCatching { file.writeText(JSONObject().put("day", today.toString()).put("rules", EVENT_IDEA_RULES).put("ideas", JSONArray(thisWeek.map { h ->
val ids = h.getJSONArray("films").let { a -> (0 until a.length()).map { a.getString(it) } }
JSONObject(h.toString()).put("details", JSONArray(ids.mapNotNull { films[it] ?: read.firstOrNull { r -> r.id == it } }.map { f ->
JSONObject().put("title", f.title).put("year", f.year ?: JSONObject.NULL).put("community", f.community ?: JSONObject.NULL).put("rating", f.rating) }))
@@ -88,15 +88,15 @@ internal val GenreSynonyms = mapOf("Anime" to "Animation", "Martial Arts" to "Ac
"Kids" to "Children", "Suspense" to "Thriller", "Sports" to "Sport")
private val Uk = Regex("United Kingdom|England|Scotland|Wales|\\bUK\\b")
private val UkStudio = Regex("\\bBBC\\b|\\bITV\\b|Channel 4|Film4|Sky\\b|Working Title|Ealing|Hammer|Aardman", RegexOption.IGNORE_CASE)
private val EnglishCountries = Regex("United States|United Kingdom|Canada|Australia|Ireland|New Zealand|England|Scotland|Wales")
internal val EnglishCountries = Regex("United States|United Kingdom|Canada|Australia|Ireland|New Zealand|England|Scotland|Wales")
private val ForeignBoard = Regex("^(SG|BR|PL|KR|JP|DE|FR|IT|ES|NL|TR|IN|MX|CN|HK|TW|TH|PH|MY|ID|RU|SE|NO|DK|FI|AR|CL|CO|PT|CZ|HU|RO|GR|IL|EG|SA|AE|VN)-", RegexOption.IGNORE_CASE)
/** Mainstream audience ratings: a horror-comedy rated these (or for children) keeps its usual channels. */
private val MainstreamRatings = setOf("PG-13", "12", "12A", "TV-14", "GB-12", "GB-12A", "GB-15", "15")
/** Paul, 5 November 2026: Ring X is professional wrestling. A verified promotion, by studio, counts for it; MMA events go on no channel. */
private val WrestlingPromotion = Regex("\\b(WWE|World Wrestling|All Elite Wrestling|AEW|TNA|Total Nonstop|Impact Wrestling|New Japan|NJPW|Ring of Honor|ROH|NWA|WCW|ECW|Lucha|Stardom|Game Changer Wrestling)\\b", RegexOption.IGNORE_CASE)
private val MmaEvent = Regex("\\b(UFC|MMA|mixed martial|Bellator|PFL|Ultimate Fighting|cage (fight|bout)|octagon)", RegexOption.IGNORE_CASE)
internal val WrestlingPromotion = Regex("\\b(WWE|World Wrestling|All Elite Wrestling|AEW|TNA|Total Nonstop|Impact Wrestling|New Japan|NJPW|Ring of Honor|ROH|NWA|WCW|ECW|Lucha|Stardom|Game Changer Wrestling)\\b", RegexOption.IGNORE_CASE)
internal val MmaEvent = Regex("\\b(UFC|MMA|mixed martial|Bellator|PFL|Ultimate Fighting|cage (fight|bout)|octagon)", RegexOption.IGNORE_CASE)
private val HorrorComedy = Regex("\\b(body horror|body-horror|gross-out|splatter|horror-comedy|horror comedy)", RegexOption.IGNORE_CASE)
private val LiveSpecial = Regex("\\b(stand-up|standup|comedian|comedy special|unruly set|in this set|live from|in concert|tour film|takes aim at)\\b", RegexOption.IGNORE_CASE)
internal val LiveSpecial = Regex("\\b(stand-up|standup|comedian|comedy special|unruly set|in this set|live from|in concert|tour film|takes aim at)\\b", RegexOption.IGNORE_CASE)
private val EventFilmWords = Regex("superhero|epic|franchise|saga|blockbuster", RegexOption.IGNORE_CASE)
private val International = setOf(305, 601, 401)
private val AnimatedChannels = setOf(105, 601, 602, 603, 604, 605, 611, 612, 613, 614)
@@ -22,19 +22,61 @@ import java.time.ZoneId
*/
internal const val EVENT_IDEAS_PER_WEEK = 2
/** An idea needs this average viewer rating across the films that have one, to be worth an evening. */
internal const val EVENT_IDEA_BAR = 6.4
internal const val EVENT_IDEA_BAR = 6.8
/** No film on a bill below this rating, however strong the rest. */
internal const val EVENT_IDEA_FLOOR = 6.2
/** The idea rules' version: ideas shown under older rules are withdrawn (and kept in the history as such) when the rules change. */
internal const val EVENT_IDEA_RULES = 3
/** How many candidate films' evidence the idea step may read in a day, read only. */
internal const val EVENT_IDEA_READS_PER_DAY = 400
internal enum class IdeaFormat(val label: String, val hour: Int, val films: Int, val days: Set<DayOfWeek>) {
SaturdayTriple("Saturday triple bill", 19, 3, setOf(DayOfWeek.SATURDAY)),
FridayDouble("Friday double bill", 20, 2, setOf(DayOfWeek.FRIDAY)),
LateShow("The Late Show double", 23, 2, setOf(DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY, DayOfWeek.THURSDAY))
/** Each format's evening, its number of films and the longest it may run in all (a Late Show ends by about 3am). */
internal enum class IdeaFormat(val label: String, val hour: Int, val films: Int, val days: Set<DayOfWeek>, val maxMinutes: Long) {
SaturdayTriple("Saturday triple bill", 19, 3, setOf(DayOfWeek.SATURDAY), 360),
FridayDouble("Friday double bill", 20, 2, setOf(DayOfWeek.FRIDAY), 270),
LateShow("The Late Show double", 23, 2, setOf(DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY, DayOfWeek.THURSDAY), 240)
}
/** A film's place in a numbered series ("Part 2", "Vol. 1", "II"): its series and number, or null for a stand-alone title. */
internal fun seriesPart(title: String): Pair<String, Int>? {
val m = Regex("^(.*?)[\\s:,-]*(?:Part|Vol\\.?|Volume|Chapter)\\s*(\\d+|I{1,3}|IV|V)\\b", RegexOption.IGNORE_CASE).find(title)
?: Regex("^(.*?)\\s+(\\d|II|III|IV|V)$").find(title) ?: return null
val n = when (val p = m.groupValues[2].uppercase()) { "I" -> 1; "II" -> 2; "III" -> 3; "IV" -> 4; "V" -> 5; else -> p.toIntOrNull() ?: return null }
return seriesKey(m.groupValues[1]) to n
}
/**
* The films a bill takes from [ranked] (best first): never a later part without every earlier part of its series alongside it,
* at most [format]'s number of films and running time, shown in story order (by year, then part).
*/
internal fun pickFilms(ranked: List<IdeaFilm>, format: IdeaFormat): List<IdeaFilm> {
val parts = ranked.associateWith { seriesPart(it.title) }
val chosen = mutableListOf<IdeaFilm>()
for (f in ranked) {
if (chosen.size >= format.films) break
val part = parts[f]
// A later part comes with its earlier parts, or not at all.
val needed = if (part == null || part.second <= 1) listOf(f) else {
// Part 1 is often just the title ("Haunt" before "Haunt II").
val earlier = (1 until part.second).map { n -> ranked.firstOrNull { parts[it] == part.first to n || (n == 1 && parts[it] == null && seriesKey(it.title) == part.first) } }
if (earlier.any { it == null }) continue
earlier.filterNotNull() + f
}
val adding = needed.filter { it !in chosen }
if (chosen.size + adding.size > format.films) continue
if ((chosen + adding).sumOf { (it.runtimeMs ?: 0L) / 60_000 } > format.maxMinutes) continue
chosen += adding
}
return chosen.sortedWith(compareBy<IdeaFilm> { it.year ?: 9999 }.thenBy { parts[it]?.second ?: 0 })
}
/** A film an idea can use, from the catalogue and (where read) its evidence. */
internal class IdeaFilm(val id: String, val title: String, val year: Int?, val imdb: String?, val runtimeMs: Long?, val rating: String, val genres: Set<String>,
val studios: List<String>, val directors: List<String> = emptyList(), val leads: List<String> = emptyList(), val community: Double? = null)
val studios: List<String>, val directors: List<String> = emptyList(), val leads: List<String> = emptyList(), val community: Double? = null,
/** Production countries, where read: a person's night is English-language unless a studio or franchise makes it an occasion. */
val countries: List<String> = emptyList(),
/** On the library's acclaim or trending lists (IMDB Top 250, Letterboxd Top 500, Trending): what makes a name an occasion. */
val notable: Boolean = false)
internal class EventIdea(val key: String, val theme: String, val source: String, val format: IdeaFormat, val evening: LocalDateTime?, val films: List<IdeaFilm>,
val reasons: List<String>, val score: Double) {
@@ -62,8 +104,8 @@ internal val EventStudios: List<Triple<String, String, String>> = listOf(
internal class IdeaSeed(val key: String, val theme: String, val source: String, val members: List<String>, val sharedGenre: String? = null, val chronological: Boolean = false)
/**
* The seeds a theme can come from: franchise collections, the studios above, and directors and leading performers with three or
* more films whose evidence has been read. A seed that touches an existing Drive-In event's films is left out: that occasion
* The seeds a theme can come from: franchise collections, the studios above, and directors and leading performers with four or
* more films whose evidence has been read. Collections arrive in story order, as the library lists them. A seed that touches an existing Drive-In event's films is left out: that occasion
* has been had. Curated lists are never seeds.
*/
internal fun ideaSeeds(collections: List<Pair<String, List<String>>>, studios: Map<Triple<String, String, String>, List<String>>, read: List<IdeaFilm>, eventFilms: Set<String>): List<IdeaSeed> {
@@ -72,21 +114,28 @@ internal fun ideaSeeds(collections: List<Pair<String, List<String>>>, studios: M
seeds += IdeaSeed("franchise:$name", name.removeSuffix(" Collection").trim() + " Night", "franchise", members, chronological = true)
}
studios.forEach { (studio, members) -> seeds += IdeaSeed("studio:${studio.first}", studio.second, "studio", members, sharedGenre = studio.third) }
read.flatMap { f -> f.directors.take(2).map { it to f.id } }.groupBy({ it.first }, { it.second }).filterValues { it.size >= 3 }.forEach { (who, ids) ->
// A person's night uses their stand-alone films and the first of any franchise: a later part belongs to its franchise's night.
val laterParts = collections.filter { FranchiseCollection.containsMatchIn(it.first) }.flatMap { (_, members) -> members.drop(1) }.toSet()
val own = read.filter { it.id !in laterParts }
own.flatMap { f -> f.directors.take(2).map { it to f.id } }.groupBy({ it.first }, { it.second }).filterValues { it.size >= 4 }.forEach { (who, ids) ->
seeds += IdeaSeed("director:$who", "A $who Night", "director", ids)
}
read.mapNotNull { f -> f.leads.firstOrNull()?.let { it to f.id } }.groupBy({ it.first }, { it.second }).filterValues { it.size >= 3 }.forEach { (who, ids) ->
own.mapNotNull { f -> f.leads.firstOrNull()?.let { it to f.id } }.groupBy({ it.first }, { it.second }).filterValues { it.size >= 4 }.forEach { (who, ids) ->
seeds += IdeaSeed("performer:$who", "$who Night", "performer", ids)
}
return seeds.filter { seed -> seed.members.none { it in eventFilms } }
}
private fun adult(f: IdeaFilm) = f.rating in Adult || "Horror" in f.genres
/** Adult: an adult rating, or horror not rated for families and mainstream audiences. Adult films only ever go late. */
private fun adult(f: IdeaFilm) = f.rating in Adult || ("Horror" in f.genres && f.rating !in KidsOk && f.rating !in setOf("PG-13", "12", "12A", "TV-14", "GB-12", "GB-12A"))
private fun family(f: IdeaFilm) = f.rating in KidsOk && ("Family" in f.genres || "Animation" in f.genres)
/** Whether a film suits the Drive-In at all: a feature film, verified identity, not a documentary, special or TV movie. */
internal fun ideaSuitable(f: IdeaFilm): String? = when {
f.imdb.isNullOrBlank() -> "no IMDb ID to verify it by"
// Wrestling and fight cards are Ring X's, and concerts and stand-up specials aren't a night at the pictures.
"Sport" in f.genres || WrestlingPromotion.containsMatchIn(f.title + " " + f.studios.joinToString(" ")) || MmaEvent.containsMatchIn(f.title) -> "a sport event"
LiveSpecial.containsMatchIn(f.title) || Regex("\\b(Live|Concert|Tour|One Night in)\\b").containsMatchIn(f.title) || "Music" in f.genres -> "a concert or live special"
f.runtimeMs == null || f.runtimeMs < 70 * 60_000L || f.runtimeMs > 200 * 60_000L -> "not a feature length"
"Documentary" in f.genres || "TV Movie" in f.genres -> "a documentary or TV movie"
else -> null
@@ -108,15 +157,34 @@ internal fun billFor(seed: IdeaSeed, films: Map<String, IdeaFilm>, unavailable:
?: group.flatMap { it.genres }.filter { it != "Drama" }.groupingBy { it }.eachCount().filterValues { it >= 2 }.maxByOrNull { it.value }?.key ?: continue
val pool = group.filter { genre in it.genres }
val format = if (format0 == IdeaFormat.FridayDouble && pool.size >= 3) IdeaFormat.SaturdayTriple else format0
val ranked = pool.sortedWith(compareByDescending<IdeaFilm> { it.community ?: 0.0 }.thenBy { it.year ?: 9999 }).take(format.films)
if (ranked.size < 2) continue
val chosen = if (seed.chronological) ranked.sortedBy { it.year ?: 9999 } else ranked
val person = seed.source == "director" || seed.source == "performer"
// English-language, by its read production countries: unknown isn't enough.
val english = { f: IdeaFilm -> f.countries.isNotEmpty() && EnglishCountries.containsMatchIn(f.countries.joinToString(" ")) }
// A person's night needs films the audience knows them by: animation is themed by its franchise or studio instead,
// and the night is English-language (a studio or franchise makes a foreign-language occasion).
if (person && pool.count { "Animation" in it.genres } * 2 >= pool.size) { why = "animation is themed by franchise or studio"; continue }
// English-language, unless a studio makes the occasion (Toho, Shaw Brothers) or the films are on the acclaim lists.
val people = (if (seed.source == "studio") pool else pool.filter { f -> english(f) || f.notable })
// A person's night is their best work: every film 7.0 or more. Any other bill: none below the floor.
.filter { (it.community ?: 10.0) >= if (person) 7.0 else EVENT_IDEA_FLOOR }
// A short franchise is a story: its night starts at the beginning. A long one (Godzilla) can pick its best.
val story = seed.source == "franchise" && seed.members.size <= 8
val ranked = if (story) people.sortedBy { it.year ?: 9999 }
else people.sortedWith(compareByDescending<IdeaFilm> { it.community ?: 0.0 }.thenBy { it.year ?: 9999 })
val first = seed.members.mapNotNull { films[it] }.filter { ideaSuitable(it) == null }.minByOrNull { it.year ?: 9999 }
if (story && ranked.firstOrNull()?.id != first?.id) { why = "the franchise's first film isn't available"; continue }
val picked = pickFilms(ranked, format)
if (picked.size < 2) { why = "no two films fit the evening"; continue }
// A triple needs three films; two make a double, on the evening that suits their audience.
val chosen = picked
val billFormat = if (format == IdeaFormat.SaturdayTriple && chosen.size < 3) IdeaFormat.FridayDouble else format
if (person && chosen.none { it.notable }) { why = "nothing on the acclaim or trending lists to make the name an occasion"; continue }
val rated = chosen.mapNotNull { it.community }
if (rated.size * 2 < chosen.size) { why = "too few viewer ratings to judge it"; continue }
val average = rated.average()
if (average < EVENT_IDEA_BAR) { why = "rated %.1f on average, under the bar".format(average); continue }
val score = average + when (seed.source) { "franchise" -> .6; "director" -> .5; "performer" -> .4; else -> .3 } + if (chosen.size == 3) .2 else 0.0
val idea = EventIdea("${seed.key}|${format.name}", seed.theme, seed.source, format, null, chosen,
val idea = EventIdea("${seed.key}|${billFormat.name}", seed.theme, seed.source, billFormat, null, chosen,
listOf("${seed.source}: ${seed.theme}", "every film is $genre", "%.1f average viewer rating".format(average)), score)
if (best == null || idea.score > best.score) best = idea
}
@@ -144,11 +212,11 @@ internal fun weeklyEventIdeas(seeds: List<IdeaSeed>, films: Map<String, IdeaFilm
/**
* The first evening from [from] for [format]: its weekday, after the Drive-In's committed plan, with no Drive-In event that
* night, nothing competing for its audience, and not already [taken] by another idea. Looks four weeks ahead.
* night, nothing competing for its audience, and not already [taken] by another idea. Looks six weeks ahead.
*/
internal fun ideaEvening(format: IdeaFormat, from: LocalDate, planEndMs: Long, taken: Set<LocalDateTime>, filmsMs: Long, zone: ZoneId = ZoneId.systemDefault()): LocalDateTime? {
var day = from
repeat(28) {
repeat(42) {
if (day.dayOfWeek in format.days && DriveInEvents.none { it.evening == day }) {
val at = day.atTime(format.hour, 0); val ms = at.atZone(zone).toInstant().toEpochMilli()
if (ms > planEndMs && at !in taken && competingEvent(ms, ms + filmsMs, zone) == null) return at
@@ -162,11 +230,17 @@ internal fun ideaEvening(format: IdeaFormat, from: LocalDate, planEndMs: Long, t
internal object EventIdeaHistory {
fun load(file: File): List<JSONObject> = runCatching { JSONArray(file.readText()).let { a -> (0 until a.length()).map { a.getJSONObject(it) } } }.getOrDefault(emptyList())
fun dismissed(file: File): Set<String> = load(file).filter { it.optString("decision") == "dismissed" }.map { it.optString("signature") }.toSet()
/** Ideas shown under older rules and never decided are withdrawn, kept in the history with the reason. */
fun withdrawOld(file: File) = runCatching {
val all = load(file)
if (all.none { it.optInt("rules") < EVENT_IDEA_RULES && it.optString("decision") == "proposed" }) return@runCatching
file.writeText(JSONArray(all.map { if (it.optInt("rules") < EVENT_IDEA_RULES && it.optString("decision") == "proposed") JSONObject(it.toString()).put("decision", "withdrawn: rules changed") else it }).toString())
}
fun record(file: File, ideas: List<EventIdea>, nowMs: Long) = runCatching {
val all = load(file); val known = all.map { it.optString("signature") }.toSet()
val added = ideas.filter { it.signature !in known }.map { i ->
JSONObject().put("signature", i.signature).put("theme", i.theme).put("source", i.source).put("format", i.format.name).put("evening", i.evening?.toString())
.put("films", JSONArray(i.films.map { it.id })).put("titles", JSONArray(i.films.map { it.title })).put("reasons", JSONArray(i.reasons)).put("shownMs", nowMs).put("decision", "proposed")
.put("films", JSONArray(i.films.map { it.id })).put("titles", JSONArray(i.films.map { it.title })).put("reasons", JSONArray(i.reasons)).put("shownMs", nowMs).put("decision", "proposed").put("rules", EVENT_IDEA_RULES)
}
if (added.isNotEmpty()) file.writeText(JSONArray(all + added).toString())
}
@@ -0,0 +1,48 @@
package tv.orbit.classic
import org.json.JSONObject
import org.junit.Assume.assumeTrue
import org.junit.Test
import java.io.File
import java.time.LocalDate
import java.time.ZoneId
/**
* The event idea rules on copies of the box's files, run by hand:
* ORBIT_IDEAS_DIR=<folder with idea-input.json and no_backup/schedule/{evidence-0.jsonl,collections-0.jsonl}> ./gradlew :app:testDebugUnitTest --tests '*EventIdeasReplayTest*'
* idea-input.json holds the catalogue's candidate films, the films booked around now and the Drive-In's plan end. Writes
* event-ideas-replay.txt: the strongest bills and this week's two, before the box's playability check.
*/
class EventIdeasReplayTest {
private val dir = System.getenv("ORBIT_IDEAS_DIR")?.let(::File)
@Test fun replay() {
assumeTrue(dir != null && File(dir, "idea-input.json").exists())
val input = JSONObject(File(dir, "idea-input.json").readText())
fun strings(o: JSONObject, key: String) = o.optJSONArray(key)?.let { a -> (0 until a.length()).map { a.getString(it) } }.orEmpty()
val evidence = File(dir, "no_backup/schedule/evidence-0.jsonl").readLines().map { JSONObject(it) }.filter { it.optString("type") == "Movie" }.associateBy { it.getString("id") }
val notable = File(dir, "no_backup/schedule/collections-0.jsonl").readLines().map { JSONObject(it) }.filter { it.getString("name") in TrendingLists }.flatMap { strings(it, "members") }.toSet()
val films = input.getJSONArray("films").let { a -> (0 until a.length()).map { a.getJSONObject(it) } }.map { o ->
val e = evidence[o.getString("id")]
IdeaFilm(o.getString("id"), o.getString("title"), o.optInt("year").takeIf { it > 0 }, if (o.isNull("imdb")) null else o.getString("imdb"), if (o.isNull("runtime")) null else o.getLong("runtime"),
o.optString("rating"), strings(o, "genres").toSet(), strings(o, "studios"), e?.let { strings(it, "directors") }.orEmpty(), e?.let { strings(it, "leads") }.orEmpty(),
e?.takeIf { !it.isNull("community") }?.optDouble("community"), e?.let { strings(it, "countries") }.orEmpty(), o.getString("id") in notable)
}.associateBy { it.id }
val collections = File(dir, "no_backup/schedule/collections-0.jsonl").readLines().map { JSONObject(it) }.map { it.getString("name") to strings(it, "members") }
val studios = EventStudios.associateWith { st -> films.values.filter { f -> f.studios.any { it.contains(st.first) } }.map { it.id } }
val read = films.values.filter { it.id in evidence }
val held = AdventDoors.map { it.film.itemId } + NaughtyList.map { it.film.itemId } + ApprovedDebuts.map { it.film.itemId }
val seeds = ideaSeeds(collections, studios, read, DriveInEvents.flatMap { e -> e.films.map { it.itemId } }.toSet() + held)
val busy = strings(input, "busy").toSet()
val unavailable = { f: IdeaFilm -> if (f.id in busy) "booked" else if (f.id in held) "held" else null }
val bills = seeds.mapNotNull { billFor(it, films, unavailable).first }.sortedByDescending { it.score }
val zone = ZoneId.of("Europe/London")
val week = weeklyEventIdeas(seeds, films, unavailable, { format, taken -> ideaEvening(format, LocalDate.of(2026, 11, 6), input.getLong("drivePlanEnd"), taken, format.films * 2 * 3_600_000L, zone) }, emptySet())
val out = StringBuilder("seeds ${seeds.size}, bills ${bills.size}\n\nstrongest bills:\n")
bills.take(20).forEach { b -> out.appendLine("%.2f %-34s %-22s %s".format(b.score, b.theme.take(34), b.format.name, b.films.joinToString(" / ") { "${it.title} (${it.year}, ${it.community}, ${(it.runtimeMs ?: 0) / 60_000}m)" })) }
out.appendLine("\nthis week's ideas:")
week.forEach { i -> out.appendLine("${i.theme}: ${i.format.label}, ${i.evening}\n ${i.films.joinToString(" / ") { "${it.title} (${it.year})" }}\n ${i.reasons}") }
File(dir, "event-ideas-replay.txt").writeText(out.toString())
println(out)
}
}
@@ -9,8 +9,9 @@ import java.time.ZoneId
/** Drive-In event ideas (phase 1): coherent themes, the right evening for the audience, a bar to clear, and no repeats. */
class EventIdeasTest {
private val london = ZoneId.of("Europe/London")
private fun film(id: String, title: String, genres: Set<String>, rating: String = "R", community: Double? = 7.0, year: Int = 1985, minutes: Long = 95, imdb: String? = "tt$id") =
IdeaFilm(id, title, year, imdb, minutes * 60_000, rating, genres, emptyList(), community = community)
private fun film(id: String, title: String, genres: Set<String>, rating: String = "R", community: Double? = 7.0, year: Int = 1985, minutes: Long = 95, imdb: String? = "tt$id",
countries: List<String> = listOf("United States"), notable: Boolean = true) =
IdeaFilm(id, title, year, imdb, minutes * 60_000, rating, genres, emptyList(), community = community, countries = countries, notable = notable)
private val none = { _: IdeaFilm -> null as String? }
@Test fun aFranchiseOfAdultHorrorIsALateShowInStoryOrder() {
@@ -23,18 +24,27 @@ class EventIdeasTest {
}
@Test fun aFamilyFranchiseGetsAFamilyEveningAndLowBudgetCultIsWelcomeLate() {
val family = listOf(film("4", "Clay Pals", setOf("Animation", "Family"), "U", 7.6), film("5", "Clay Pals 2", setOf("Animation", "Family"), "U", 7.3),
film("6", "Clay Pals 3", setOf("Animation", "Family"), "PG", 7.1)).associateBy { it.id }
val family = listOf(film("4", "Clay Pals", setOf("Animation", "Family"), "U", 7.6, year = 1980), film("5", "Clay Pals 2", setOf("Animation", "Family"), "U", 7.3),
film("6", "Clay Pals 3", setOf("Animation", "Family"), "PG", 7.1, year = 1990)).associateBy { it.id }
assertEquals(IdeaFormat.SaturdayTriple, billFor(IdeaSeed("studio:Clay", "Clay Saturday", "studio", family.keys.toList(), "Animation"), family, none).first!!.format)
val cult = listOf(film("7", "Puppet Terror", setOf("Horror"), "R", 6.5), film("8", "Puppet Terror 2", setOf("Horror"), "R", 6.6)).associateBy { it.id }
val cult = listOf(film("7", "Puppet Terror", setOf("Horror"), "R", 6.9, notable = false), film("8", "Puppet Terror II", setOf("Horror"), "R", 7.0, year = 1989, notable = false)).associateBy { it.id }
assertNotNull(billFor(IdeaSeed("studio:Full Moon Features", "Full Moon After Midnight", "studio", cult.keys.toList(), "Horror"), cult, none).first)
}
@Test fun noCoherentThemeOrTooWeakMeansNoBill() {
val mixed = listOf(film("9", "A", setOf("Comedy"), "PG-13"), film("10", "B", setOf("Western"), "PG-13")).associateBy { it.id }
assertNull(billFor(IdeaSeed("director:X", "A X Night", "director", mixed.keys.toList()), mixed, none).first)
val weak = listOf(film("11", "C", setOf("Action"), "PG-13", 5.2), film("12", "D", setOf("Action"), "PG-13", 5.5)).associateBy { it.id }
assertTrue(billFor(IdeaSeed("performer:Y", "Y Night", "performer", weak.keys.toList()), weak, none).second.contains("under the bar"))
val weak = listOf(film("11", "C", setOf("Action"), "PG-13", 6.5), film("12", "D", setOf("Action"), "PG-13", 6.6)).associateBy { it.id }
assertTrue(billFor(IdeaSeed("studio:Y", "Y Night", "studio", weak.keys.toList(), "Action"), weak, none).second.contains("under the bar"))
// A name alone isn't an occasion: nothing acclaimed or trending, no night.
val obscure = listOf(film("17", "I", setOf("Action"), "PG-13", 7.5, notable = false), film("18", "J", setOf("Action"), "PG-13", 7.6, notable = false)).associateBy { it.id }
assertTrue(billFor(IdeaSeed("director:Q", "A Q Night", "director", obscure.keys.toList()), obscure, none).second.contains("acclaim"))
// A franchise story starts at its beginning; a foreign-language one needs a studio or acclaim.
val story = listOf(film("19", "Saga", setOf("Action"), "PG-13", 6.9, year = 1990), film("20", "Saga II", setOf("Action"), "PG-13", 7.8, year = 1992), film("21", "Saga III", setOf("Action"), "PG-13", 7.7, year = 1995)).associateBy { it.id }
assertEquals(listOf("Saga", "Saga II", "Saga III"), billFor(IdeaSeed("franchise:Saga Collection", "Saga Night", "franchise", story.keys.toList()), story, none).first!!.films.map { it.title })
val abroad = story.mapValues { (_, f) -> IdeaFilm(f.id, f.title, f.year, f.imdb, f.runtimeMs, f.rating, f.genres, f.studios, community = f.community, countries = listOf("Indonesia")) }
assertNull(billFor(IdeaSeed("franchise:Saga Collection", "Saga Night", "franchise", abroad.keys.toList()), abroad, none).first)
assertEquals("a sport event", ideaSuitable(film("22", "WWE SummerSlam 2013", setOf("Action"))))
val unrated = listOf(film("13", "E", setOf("Action"), "PG-13", null), film("14", "F", setOf("Action"), "PG-13", null)).associateBy { it.id }
assertTrue(billFor(IdeaSeed("performer:Z", "Z Night", "performer", unrated.keys.toList()), unrated, none).second.contains("ratings"))
// Unverifiable or unsuitable films don't count: no IMDb ID, a short, a TV movie.
@@ -43,14 +53,14 @@ class EventIdeasTest {
}
@Test fun atMostTwoAWeekNoneWhenNothingIsWorthItAndDismissedThemesStayDismissed() {
val films = (1..12).map { film("$it", "Film $it", setOf("Action"), "PG-13", 7.0 + it / 20.0) }.associateBy { it.id }
val films = (1..12).map { film("$it", "Picture ${'A' + it}", setOf("Action"), "PG-13", 7.0 + it / 20.0) }.associateBy { it.id }
val seeds = (0 until 4).map { k -> IdeaSeed("director:D$k", "A D$k Night", "director", (1..12).filter { it % 4 == k }.map { "$it" }) }
var day = LocalDate.of(2026, 12, 1)
val evening = { _: IdeaFormat, taken: Set<java.time.LocalDateTime> -> generateSequence(day.atTime(20, 0)) { it.plusDays(1) }.first { it !in taken } }
val week = weeklyEventIdeas(seeds, films, none, evening, emptySet())
assertEquals(2, week.size); assertNotEquals(week[0].evening, week[1].evening)
assertTrue(weeklyEventIdeas(seeds, films, none, evening, emptySet(), limit = 0).isEmpty())
val weak = films.mapValues { (_, f) -> IdeaFilm(f.id, f.title, f.year, f.imdb, f.runtimeMs, f.rating, f.genres, f.studios, community = 5.0) }
val weak = films.mapValues { (_, f) -> IdeaFilm(f.id, f.title, f.year, f.imdb, f.runtimeMs, f.rating, f.genres, f.studios, community = 6.5, notable = true) }
assertTrue(weeklyEventIdeas(seeds, weak, none, evening, emptySet()).isEmpty()) // no filler
val dismissed = setOf(week[0].signature)
assertTrue(weeklyEventIdeas(seeds, films, none, evening, dismissed).none { it.signature == week[0].signature })
@@ -0,0 +1,18 @@
seeds 323, bills 7
strongest bills:
7.70 Ant-Man Night FridayDouble Ant-Man (2015, 7.2, 117m) / Ant-Man and the Wasp (2018, 7.0, 118m)
7.60 1981 Night FridayDouble 1991 (2018, 7.1, 101m) / 1995 (2024, 6.9, 119m)
7.60 Toho Monster Night LateShow Godzilla (1954, 7.6, 96m) / Godzilla, Mothra and King Ghidorah: Giant Monsters All-Out Attack (2001, 7.0, 105m)
7.55 Addams Family Night FridayDouble The Addams Family (1991, 7.0, 101m) / Addams Family Values (1993, 6.9, 94m)
7.50 300 Night LateShow 300 (2007, 7.6, 116m) / 300: Rise of an Empire (2014, 6.2, 102m)
7.40 Arsène Lupin Night FridayDouble Arsène Lupin (1932, 6.9, 84m) / Arsène Lupin Returns (1938, 6.7, 80m)
7.20 New World Midnight Movies LateShow The Velvet Vampire (1971, null, 80m) / Hellraiser (1987, 6.9, 93m)
this week's ideas:
Ant-Man Night: Friday double bill, 2026-12-04T20:00
Ant-Man (2015) / Ant-Man and the Wasp (2018)
[franchise: Ant-Man Night, every film is Science Fiction, 7.1 average viewer rating]
Toho Monster Night: The Late Show double, 2026-11-18T23:00
Godzilla (1954) / Godzilla, Mothra and King Ghidorah: Giant Monsters All-Out Attack (2001)
[studio: Toho Monster Night, every film is Science Fiction, 7.3 average viewer rating]