import { describeHost, resolveForHost, type HostMachine } from '../../domain/models/HostMachine' import type { UnavailableReason } from '../../domain/models/Game' import { gameKey } from '../../domain/models/InstalledRecord' import type { CatalogSurvey, SelectedGame, UnavailableEntry } from '../../domain/models/SelectedGame' import type { AssetSpecification, StoreConfiguration } from '../../domain/models/StoreConfiguration' import type { CatalogEntry, CatalogSoftware } from './dialects/CatalogDialect' import { pickRelease } from './ReleasePicker' /** * The catalog, turned into what this machine can and cannot install. * * Two decisions layered on each other. The inner one asks, per install mode, "which * titles have *this* asset for this host"; the outer one asks it once per mode and * merges the answers in the config's mode order, so a title with no native build for * this machine is still installable as a hosted page. That fallback is what makes a * desktop store the only one that can carry the whole catalog. * * A title counts as unavailable only when every mode failed it, and then the *first* * mode's verdict is the one kept: `app` comes first, so a person is told "no native * build for this machine" rather than the web mode's complaint about the same title. * * The entries arrive already typed, from whichever `CatalogDialect` the serving engine * version selected — nothing here knows what the catalog's JSON looks like. */ export class CatalogSurveyor { public constructor ( private readonly configuration: StoreConfiguration, private readonly log: (line: string) => void ) {} public survey (entries: readonly CatalogEntry[], host: HostMachine): CatalogSurvey { const chosen = new Map() const unmet = new Map() const reasonsByName = new Map() for (const mode of this.configuration.install.modes) { const specification = this.configuration.install.specifications[mode] if (specification === undefined) { this.log(`warning: install mode '${mode}' has no asset spec — ignoring it`) continue } const pass = this.surveyMode(entries, host, mode, specification) for (const game of pass.games) { const key = gameKey(game) if (!chosen.has(key)) chosen.set(key, game) } for (const [name, reason] of pass.reasons) { const collected = reasonsByName.get(name) ?? [] collected.push(`${mode}: ${reason}`) reasonsByName.set(name, collected) } for (const entry of pass.unavailable) { if (!unmet.has(entry.name)) unmet.set(entry.name, entry) } } // A title that some later mode could serve is not skipped at all: nobody needs to // hear that the native build was missing when the game installed anyway. const installedNames = new Set([...chosen.values()].map((game: SelectedGame): string => game.name)) const skipped = [...reasonsByName.entries()] .filter(([name]: readonly [string, readonly string[]]): boolean => !installedNames.has(name)) .map(([name, reasons]: readonly [string, readonly string[]]): string => `${name}: ${reasons.join('; ')}`) return { games: [...chosen.values()], skipped, unavailable: [...unmet.values()] .filter((entry: UnavailableEntry): boolean => !installedNames.has(entry.name)) } } /** One pass over the catalog, asking for one mode's asset. */ private surveyMode ( entries: readonly CatalogEntry[], host: HostMachine, mode: string, specification: AssetSpecification ): ModeSurvey { const { statuses, only, exclude } = this.filters() const games: SelectedGame[] = [] const reasons: [string, string][] = [] const unavailable: UnavailableEntry[] = [] for (const entry of entries) { const software = entry.software const name = software.name // Editorial filters: a title excluded here is in none of the three lists, because // that is the store's choice rather than a limit of the machine. if (statuses.size > 0 && !statuses.has(software.status.toLowerCase())) continue if (only.size > 0 && !only.has(name.toLowerCase())) continue if (exclude.has(name.toLowerCase())) continue const platform = this.configuration.platforms[software.platform] if (platform?.enabled !== true) { // Reported rather than hidden: to somebody looking at a catalog, a platform // switched off reads as "not supported here". unavailable.push(toUnavailable(entry, 'platformOff', `${software.platform} is not carried by this store`)) continue } const wanted = readKinds(resolveForHost(specification.kind, host)) if (wanted.length === 0) { const reason = `${software.platform} has no asset kind for ${describeHost(host)}` reasons.push([name, reason]) unavailable.push(toUnavailable(entry, 'hostAsset', reason)) continue } const extension = resolveForHost(specification.extension, host) ?? '' const release = pickRelease(entry.releaseCandidates, wanted, extension) if (release === null) { const reason = `no '${wanted.join('/')}' asset in any release` reasons.push([name, reason]) unavailable.push(toUnavailable(entry, 'noAsset', reason)) continue } games.push({ name, scope: software.platform, platform: software.platform, kind: release.kind, version: release.version, asset: release.assetName, assetPath: release.assetPath, title: software.title, description: software.description, author: software.author, imageUrl: software.imageUrl, createdAt: release.createdAt, mode }) } return { games, reasons, unavailable } } private filters (): CatalogFilters { const lower = (values: readonly string[]): ReadonlySet => new Set(values.map((value: string): string => value.toLowerCase())) return { statuses: lower(this.configuration.catalog.statuses), only: lower(this.configuration.catalog.only), exclude: lower(this.configuration.catalog.exclude) } } } interface CatalogFilters { readonly statuses: ReadonlySet readonly only: ReadonlySet readonly exclude: ReadonlySet } interface ModeSurvey { readonly games: readonly SelectedGame[] /** `[name, reason]`, kept per name so several modes' complaints can be joined. */ readonly reasons: readonly (readonly [string, string])[] readonly unavailable: readonly UnavailableEntry[] } /** One unavailable record, with enough for a client to draw a card. */ function toUnavailable ( entry: CatalogEntry, reason: UnavailableReason, detail: string ): UnavailableEntry { const software: CatalogSoftware = entry.software return { name: software.name, title: software.title, platform: software.platform, description: software.description, author: software.author, imageUrl: software.imageUrl, version: entry.latestRelease?.version ?? '', reason, detail } } function readKinds (value: string | readonly string[] | null): readonly string[] { if (value === null) return [] const kinds = typeof value === 'string' ? [value] : value return kinds.filter((kind: string): boolean => kind.length > 0) }