GEN-3c: fix 'Unbekannter Farbschlag' — unknown=wildcard in matches() + E-pair-aware family fallback (eef=Fuchsschimmel)

This commit is contained in:
2026-06-06 10:45:15 +02:00
parent 2e20df624f
commit c35a6eca04
2 changed files with 90 additions and 16 deletions

View File

@@ -20,9 +20,15 @@
* meta rows dropped, 17 matched the frozen names). Genotypes normalized from
* portal notation (c[chm]->cchm, c[h]->ch, e[f]->ef, '-'/'--' = unknown).
*/
import { LOCUS_ORDER, type LocusKey } from './loci'
import { makeGenotype, toDisplayString, wildType, type AllelePair, type Genotype } from './genotype'
import { phenotypeTokens, type PhenotypeTokens } from './phenotype'
import { LOCUS_ORDER, dominantAllele, type LocusKey } from './loci'
import {
makeGenotype,
toDisplayString,
wildType,
WILDCARD,
type AllelePair,
type Genotype,
} from './genotype'
export interface FarbschlagEntry {
/** German variety name. FROZEN for the original 18 (DB keys). */
@@ -138,25 +144,64 @@ export interface FarbschlagMatch {
readonly unknown: boolean
}
function matches(tokens: PhenotypeTokens, entry: FarbschlagEntry): boolean {
return (Object.keys(entry.tokens) as LocusKey[]).every(
(locus) => tokens[locus] === entry.tokens[locus],
)
/**
* Expressed token at a locus, or null when UNKNOWN (a '?' allele) — null acts as
* a wildcard in matching. The E locus is PAIR-aware so the Fuchs/Schimmel family
* is distinguishable: ee->'e', e/ef->'eef' (Fuchsschimmel), ef/ef->'ef' (Schimmel).
*/
function locusToken(g: Genotype, locus: LocusKey): string | null {
const [x, y] = g[locus]
if (x === WILDCARD || y === WILDCARD) return null
if (locus === 'E') {
if (x === y) return x // ee->'e', efef->'ef', EE->'E'
if ((x === 'e' && y === 'ef') || (x === 'ef' && y === 'e')) return 'eef'
return dominantAllele('E', x, y) // E/ef, E/e -> 'E'
}
return dominantAllele(locus, x, y)
}
function matches(g: Genotype, entry: FarbschlagEntry): boolean {
// Unknown loci (null token) match anything (treated as wildcard, GEN-3c).
return (Object.keys(entry.tokens) as LocusKey[]).every((locus) => {
const t = locusToken(g, locus)
return t === null || t === entry.tokens[locus]
})
}
/**
* GEN-3c family fallback: the E locus alone names the Fuchs/Schimmel family even
* when other loci are unknown (so genotypes never fall through to "Unbekannt").
* ee -> Fuchs | e/ef -> Fuchsschimmel | ef/ef -> Schimmel | e/? -> Fuchs (for now)
* Returns null when E is dominant (full colour) or fully unknown.
*/
function eFamily(g: Genotype): string | null {
const [x, y] = g.E
if (x === 'e' && y === 'e') return 'Fuchs'
if ((x === 'e' && y === 'ef') || (x === 'ef' && y === 'e')) return 'Fuchsschimmel'
if (x === 'ef' && y === 'ef') return 'Schimmel'
if ((x === 'e' || y === 'e') && (x === WILDCARD || y === WILDCARD)) return 'Fuchs'
return null
}
/** Resolve a genotype to its German Farbschlag (with Schecke/Rex modifiers). */
export function farbschlagFor(g: Genotype): FarbschlagMatch {
const tokens = phenotypeTokens(g)
const base = BASE_COLORS.find((e) => matches(tokens, e)) ?? null
const family = eFamily(g)
// When the animal is a Fuchs/Schimmel-family genotype, only entries that
// explicitly pin the E locus may name it (so e.g. eef can't masquerade as a
// C-series white via wildcard C); otherwise fall back to the family name.
const base = family
? (BASE_COLORS.find((e) => e.tokens.E !== undefined && matches(g, e)) ?? null)
: (BASE_COLORS.find((e) => matches(g, e)) ?? null)
const modifiers: string[] = []
if (tokens.Sp === 'Sp') modifiers.push('Schecke')
if (tokens.Re === 'Re') modifiers.push('Rex')
if (locusToken(g, 'Sp') === 'Sp') modifiers.push('Schecke')
if (locusToken(g, 'Re') === 'Re') modifiers.push('Rex')
if (!base) {
const baseName = base?.name ?? family
if (!baseName) {
return { name: UNKNOWN_FARBSCHLAG, base: null, unknown: true }
}
const name = [base.name, ...modifiers].join(' ')
const name = [baseName, ...modifiers].join(' ')
return { name, base, unknown: false }
}