Files
Beug/src/engine.js
T
georgeandClaude Opus 5 e448045e52 ein-Wörter was quietly dropping three of the nine dative prepositions
Reported as "not a single dative in the ein-Wörter section". The engine did
still produce dative questions, but only from 6 of the 9 dative
prepositions, so the dative was thinner than the lesson claimed and leant
on Wechsel preps landing on dative rather than on dative preps themselves.

The indefOnly frame filter was throwing three away:

- bei — a regression from the "bei der Schwester" fix. That commit added
  `!f.enPossS` to the filter, which killed bei's only non-possessive frame
  outright. "bei einem Arzt" is perfectly good German and reads as "at a
  doctor's" in English, so there was never a reason to exclude it. von and
  zu survived but lost their person frames the same way, halving them.

- nach, seit — excluded as "definite idioms" because their frames carry an
  enDet override ("nach der Schule" is "after school", not "after the
  school"). But "nach einer Woche" and "seit einer Woche" are textbook
  German; the engine's own PREPS entry for seit uses "seit einer Woche" as
  its example.

The enDet exclusion existed to stop "während einer Woche", the sentence
reported a while back, so it could not simply be deleted. Frames can now
name `indefNouns`: the nouns that DO work once an ein-word is in front,
overriding the blanket noIndef flag for that frame only. nach and seit
re-admit Woche; während re-admits nothing, so "während einer Woche" stays
impossible. seit also gets enPrepIndef, because "seit einer Woche" is "FOR
a week" in English, not "since a week".

trotz stays out, but honestly: it only governs uncountables here ("trotz
eines Wetters" is not German), so it is removed from level 7's preps list
rather than left advertised and unreachable — the same treatment zu got in
the Dativ lesson.

The guard that should have caught this: a new test asserts that every level
generates every preposition it lists. A prep that can never be asked about
is now a test failure, not a silent hole. Two older tests that asserted the
buggy behaviour (that nach/seit never appear, that no noIndef noun ever
appears) are rewritten to assert the actual rule instead of being loosened.

Dative coverage in ein-Wörter: 6/9 prepositions before, 9/9 after. Case mix
47% dative / 32% accusative / 21% genitive. A round of 10 with no dative at
all went from 0.2% to 0.06%.

142 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Uza6mhTpAvSwMQuFc4Cz8
2026-10-01 06:39:45 +00:00

1527 lines
86 KiB
JavaScript

// ═══════════════════════════════════════════
// ENGINE — data, question generators, streaks
// Pure JS, no React. Everything here is unit-testable.
// ═══════════════════════════════════════════
export const DEF = {
masculine:{nominative:'der',accusative:'den',dative:'dem',genitive:'des'},
feminine: {nominative:'die',accusative:'die',dative:'der',genitive:'der'},
neuter: {nominative:'das',accusative:'das',dative:'dem',genitive:'des'},
plural: {nominative:'die',accusative:'die',dative:'den',genitive:'der'},
};
export const INDEF = {
masculine:{nominative:'ein', accusative:'einen',dative:'einem',genitive:'eines'},
feminine: {nominative:'eine',accusative:'eine', dative:'einer',genitive:'einer'},
neuter: {nominative:'ein', accusative:'ein', dative:'einem',genitive:'eines'},
plural: {nominative:'keine',accusative:'keine',dative:'keinen',genitive:'keiner'},
};
export const STRONG = {
masculine:{nominative:'r',accusative:'n',dative:'m',genitive:'n'},
feminine: {nominative:'e',accusative:'e',dative:'r',genitive:'r'},
neuter: {nominative:'s',accusative:'s',dative:'m',genitive:'n'},
plural: {nominative:'e',accusative:'e',dative:'n',genitive:'r'},
};
export const WEAK = {
masculine:{nominative:'e',accusative:'n',dative:'n',genitive:'n'},
feminine: {nominative:'e',accusative:'e',dative:'n',genitive:'n'},
neuter: {nominative:'e',accusative:'e',dative:'n',genitive:'n'},
plural: {nominative:'n',accusative:'n',dative:'n',genitive:'n'},
};
export const PREPS = {
durch:{c:'accusative',en:'through'}, für:{c:'accusative',en:'for'},
gegen:{c:'accusative',en:'against'}, ohne:{c:'accusative',en:'without'},
um:{c:'accusative',en:'around'},
bis:{c:'accusative',en:'until', ex:{de:'bis nächsten Montag', en:'until next Monday'}},
aus:{c:'dative',en:'out of'}, bei:{c:'dative',en:'at'},
mit:{c:'dative',en:'with'}, nach:{c:'dative',en:'to/after'},
seit:{c:'dative',en:'since', ex:{de:'seit einer Woche', en:'for/since a week'}},
von:{c:'dative',en:'from'},
zu:{c:'dative',en:'to'}, außer:{c:'dative',en:'except'},
gegenüber:{c:'dative',en:'opposite'},
trotz:{c:'genitive',en:'despite'}, während:{c:'genitive',en:'during'},
wegen:{c:'genitive',en:'because of'}, statt:{c:'genitive',en:'instead of'},
anstatt:{c:'genitive',en:'instead of'}, außerhalb:{c:'genitive',en:'outside'},
innerhalb:{c:'genitive',en:'inside'},
an:{c:'wechsel',en:'at/on',m:'to',l:'at'},
auf:{c:'wechsel',en:'on',m:'onto',l:'on'},
hinter:{c:'wechsel',en:'behind',m:'behind',l:'behind'},
in:{c:'wechsel',en:'in/into',m:'into',l:'in'},
neben:{c:'wechsel',en:'next to',m:'next to',l:'next to'},
über:{c:'wechsel',en:'over/above',m:'over',l:'above'},
unter:{c:'wechsel',en:'under',m:'under',l:'under'},
vor:{c:'wechsel',en:'in front of',m:'in front of',l:'in front of'},
zwischen:{c:'wechsel',en:'between',m:'between',l:'between'},
};
export const PREPS_BY_CASE = {
accusative:['durch','für','gegen','ohne','um','bis'],
dative:['aus','bei','mit','nach','seit','von','zu','außer','gegenüber'],
genitive:['trotz','während','wegen','statt','anstatt','außerhalb','innerhalb'],
wechsel:['an','auf','hinter','neben','unter','über','in','vor','zwischen'],
};
export const COMMON_PREPS = ['mit','von','zu','in','an','auf','für','durch','ohne','um','aus','bei','nach','vor','über','wegen'];
export const RARE_PREPS = ['gegen','bis','seit','außer','gegenüber','hinter','neben','unter','zwischen','trotz','während','statt','anstatt','außerhalb','innerhalb'];
export const ALL_PREPS = [...COMMON_PREPS, ...RARE_PREPS];
// "bis" almost never takes a bare definite article in real German
// (bis nächsten Montag / bis zum Ende) — so it stays in the case-ID
// quizzes but is excluded from fill-the-article sentence levels.
const SENTENCE_ACC_PREPS = ['durch','für','gegen','ohne','um'];
// "zu" always fuses with a definite article (zum/zur), so a fill-the-article
// sentence can never show it honestly — it lives in the Kontraktionen levels
// and the mixed rounds, which teach the fused form properly.
const SENTENCE_DAT_PREPS = PREPS_BY_CASE.dative.filter(p => p !== 'zu');
// "trotz eines Wetters" is not German — trotz only governs uncountables here,
// so the ein-Wörter lesson leaves it out rather than listing a prep it can
// never ask about.
const INDEF_GEN_PREPS = PREPS_BY_CASE.genitive.filter(p => p !== 'trotz');
export const WECHSEL_EX = {
an: {noun:'Tür', g:'feminine', en:'door', mot:'to', loc:'at'},
auf: {noun:'Tisch', g:'masculine', en:'table', mot:'onto', loc:'on'},
hinter: {noun:'Haus', g:'neuter', en:'house', mot:'behind', loc:'behind'},
in: {noun:'Park', g:'masculine', en:'park', mot:'into', loc:'in'},
neben: {noun:'Tisch', g:'masculine', en:'table', mot:'next to', loc:'next to'},
über: {noun:'Brücke', g:'feminine', en:'bridge', mot:'over', loc:'above'},
unter: {noun:'Brücke', g:'feminine', en:'bridge', mot:'under', loc:'under'},
vor: {noun:'Haus', g:'neuter', en:'house', mot:'in front of', loc:'in front of'},
zwischen: {noun:'Bäume', g:'plural', en:'trees', mot:'between', loc:'between', plDat:'n'},
};
export function wechselExample(prepKey, isMotion) {
const ex = WECHSEL_EX[prepKey];
const cas = isMotion ? 'accusative' : 'dative';
const art = DEF[ex.g][cas];
let n = ex.noun;
if (ex.g === 'plural' && !isMotion && ex.plDat) n += ex.plDat;
return { de: `${prepKey} ${art} ${n}`, en: `${isMotion ? ex.mot : ex.loc} the ${ex.en}` };
}
// gen: genitive noun form for masculine/neuter (des Mannes, des Hauses)
// noIndef: never combine with ein-words — mass nouns („eines Wetters" is not
// German) and nouns whose frames only read naturally with a definite article
// (kinship terms, temporal Nacht/Woche: „während einer Woche" is off).
export const NOUNS = [
{w:'Hund',g:'masculine',en:'dog',gen:'Hundes'},
{w:'Mann',g:'masculine',en:'man',gen:'Mannes',enPoss:'husband'},
{w:'Tisch',g:'masculine',en:'table',gen:'Tisches'},
{w:'Park',g:'masculine',en:'park',gen:'Parks'},
{w:'Arzt',g:'masculine',en:'doctor',gen:'Arztes'},
{w:'Freund',g:'masculine',en:'friend',gen:'Freundes'},
{w:'Baum',g:'masculine',en:'tree',gen:'Baumes'},
{w:'Garten',g:'masculine',en:'garden',gen:'Gartens'},
{w:'Stuhl',g:'masculine',en:'chair',gen:'Stuhls'},
{w:'Bruder',g:'masculine',en:'brother',gen:'Bruders',noIndef:true},
{w:'Lehrer',g:'masculine',en:'teacher',gen:'Lehrers'},
{w:'Vater',g:'masculine',en:'father',gen:'Vaters',noIndef:true},
{w:'Zug',g:'masculine',en:'train',gen:'Zuges'},
{w:'Regen',g:'masculine',en:'rain',gen:'Regens',noIndef:true},
{w:'Frau',g:'feminine',en:'woman',enPoss:'wife'},
{w:'Ärztin',g:'feminine',en:'doctor'},
{w:'Lehrerin',g:'feminine',en:'teacher'},
{w:'Freundin',g:'feminine',en:'friend'},
{w:'Katze',g:'feminine',en:'cat'},
{w:'Schule',g:'feminine',en:'school'},
{w:'Stadt',g:'feminine',en:'city'},
{w:'Straße',g:'feminine',en:'street'},
{w:'Tür',g:'feminine',en:'door'},
{w:'Schwester',g:'feminine',en:'sister',noIndef:true},
{w:'Mutter',g:'feminine',en:'mother',noIndef:true},
{w:'Tochter',g:'feminine',en:'daughter',noIndef:true},
{w:'Musik',g:'feminine',en:'music',noIndef:true},
{w:'Nacht',g:'feminine',en:'night',noIndef:true},
{w:'Woche',g:'feminine',en:'week',noIndef:true},
{w:'Arbeit',g:'feminine',en:'work',noIndef:true},
{w:'Brücke',g:'feminine',en:'bridge'},
{w:'Kind',g:'neuter',en:'child',gen:'Kindes'},
{w:'Haus',g:'neuter',en:'house',gen:'Hauses'},
{w:'Buch',g:'neuter',en:'book',gen:'Buches'},
{w:'Auto',g:'neuter',en:'car',gen:'Autos'},
{w:'Mädchen',g:'neuter',en:'girl',gen:'Mädchens'},
{w:'Fenster',g:'neuter',en:'window',gen:'Fensters'},
{w:'Hotel',g:'neuter',en:'hotel',gen:'Hotels'},
{w:'Museum',g:'neuter',en:'museum',gen:'Museums'},
{w:'Büro',g:'neuter',en:'office',gen:'Büros'},
{w:'Restaurant',g:'neuter',en:'restaurant',gen:'Restaurants'},
{w:'Wetter',g:'neuter',en:'weather',gen:'Wetters',noIndef:true},
{w:'Dach',g:'neuter',en:'roof',gen:'Daches'},
{w:'Konzert',g:'neuter',en:'concert',gen:'Konzerts'},
{w:'See',g:'masculine',en:'lake',gen:'Sees'},
{w:'Fluss',g:'masculine',en:'river',gen:'Flusses'},
{w:'Berg',g:'masculine',en:'mountain',gen:'Berges'},
{w:'Wald',g:'masculine',en:'forest',gen:'Waldes'},
{w:'Bahnhof',g:'masculine',en:'station',gen:'Bahnhofs'},
{w:'Flughafen',g:'masculine',en:'airport',gen:'Flughafens'},
{w:'Schrank',g:'masculine',en:'cupboard',gen:'Schranks'},
{w:'Boden',g:'masculine',en:'floor',gen:'Bodens'},
{w:'Supermarkt',g:'masculine',en:'supermarket',gen:'Supermarkts'},
{w:'Keller',g:'masculine',en:'cellar',gen:'Kellers'},
{w:'Wand',g:'feminine',en:'wall'},
{w:'Küche',g:'feminine',en:'kitchen'},
{w:'Wohnung',g:'feminine',en:'flat'},
{w:'Tafel',g:'feminine',en:'blackboard'},
{w:'Wiese',g:'feminine',en:'meadow'},
{w:'Tasche',g:'feminine',en:'bag'},
{w:'Kirche',g:'feminine',en:'church'},
{w:'Bibliothek',g:'feminine',en:'library'},
{w:'Bank',g:'feminine',en:'bench'},
{w:'Bett',g:'neuter',en:'bed',gen:'Bettes'},
{w:'Zimmer',g:'neuter',en:'room',gen:'Zimmers'},
{w:'Regal',g:'neuter',en:'shelf',gen:'Regals'},
{w:'Sofa',g:'neuter',en:'sofa',gen:'Sofas'},
{w:'Meer',g:'neuter',en:'sea',gen:'Meeres'},
{w:'Dorf',g:'neuter',en:'village',gen:'Dorfes'},
{w:'Bild',g:'neuter',en:'picture',gen:'Bildes'},
{w:'Wasser',g:'neuter',en:'water',gen:'Wassers',noIndef:true},
{w:'Brot',g:'neuter',en:'bread',gen:'Brotes',noIndef:true},
{w:'Bier',g:'neuter',en:'beer',gen:'Bieres',noIndef:true},
{w:'Geld',g:'neuter',en:'money',gen:'Geldes',noIndef:true},
{w:'Kaffee',g:'masculine',en:'coffee',gen:'Kaffees',noIndef:true},
{w:'Tee',g:'masculine',en:'tea',gen:'Tees',noIndef:true},
{w:'Wein',g:'masculine',en:'wine',gen:'Weines',noIndef:true},
{w:'Milch',g:'feminine',en:'milk',noIndef:true},
{w:'Zeit',g:'feminine',en:'time',noIndef:true},
];
const NOUN_BY_NAME = Object.fromEntries(NOUNS.map(n => [n.w, n]));
export const nounForm = (noun, cas) =>
cas === 'genitive' && noun.gen ? noun.gen : noun.w;
const PERSONS = ['Mann','Arzt','Freund','Bruder','Lehrer','Vater','Frau','Schwester','Mutter','Tochter','Kind','Mädchen','Ärztin','Lehrerin','Freundin'];
// zu/von/bei + a person means "to/from/at their PLACE". With a bare definite
// article that only sounds right for roles you visit (beim Arzt, zur Ärztin);
// for family German wants a possessive (bei meiner Schwester), so those frames
// are marked possOnly. English always needs the -'s.
const VISITABLE = ['Arzt','Lehrer','Freund','Ärztin','Lehrerin','Freundin'];
const PLACES_IN = ['Park','Garten','Schule','Stadt','Haus','Hotel','Museum','Büro','Restaurant'];
// Nouns that read naturally with a possessive ("my …")
const POSSESSABLE = new Set([
...PERSONS, 'Hund','Katze','Haus','Garten','Auto','Buch','Tisch','Stuhl',
'Büro','Schule','Stadt','Fenster','Tür','Musik',
'Wohnung','Zimmer','Küche','Tasche','Bett','Sofa','Schrank','Regal','Wand','Bild',
]);
POSSESSABLE.delete('Mädchen'); // "mein Mädchen" is not idiomatic
// German possessives reinterpret these: mein Mann = my husband, meine Frau = my wife.
export const possNounEn = noun => noun.enPoss || noun.en;
export const PRONOUNS = [
{key:'ich', nom:'ich', acc:'mich', dat:'mir', en:'I', enObj:'me', tag:'1st person', num:'sg'},
{key:'du', nom:'du', acc:'dich', dat:'dir', en:'you', enObj:'you', tag:'2nd informal', num:'sg'},
{key:'Sie_sg', nom:'Sie', acc:'Sie', dat:'Ihnen', en:'you', enObj:'you', tag:'2nd formal', num:'sg'},
{key:'er', nom:'er', acc:'ihn', dat:'ihm', en:'he', enObj:'him', tag:'3rd masculine', num:'sg'},
{key:'sie_sg', nom:'sie', acc:'sie', dat:'ihr', en:'she', enObj:'her', tag:'3rd feminine', num:'sg'},
{key:'es', nom:'es', acc:'es', dat:'ihm', en:'it', enObj:'it', tag:'3rd neuter', num:'sg'},
{key:'wir', nom:'wir', acc:'uns', dat:'uns', en:'we', enObj:'us', tag:'1st plural', num:'pl'},
{key:'ihr', nom:'ihr', acc:'euch', dat:'euch', en:"y'all", enObj:"y'all", tag:'2nd inf plural', num:'pl'},
{key:'Sie_pl', nom:'Sie', acc:'Sie', dat:'Ihnen', en:'you (formal)', enObj:'you (formal)', tag:'2nd formal pl', num:'pl'},
{key:'sie_pl', nom:'sie', acc:'sie', dat:'ihnen', en:'they', enObj:'them', tag:'3rd plural', num:'pl'},
];
export const POSSESSIVES = [
{stem:'mein', en:'my'},
{stem:'dein', en:'your (informal)'},
{stem:'sein', en:'his / its'},
{stem:'ihr', en:'her / their'},
{stem:'unser', en:'our'},
{stem:'euer', en:"y'all's"},
];
export const POSS_ENDINGS = {
masculine:{nominative:'', accusative:'en', dative:'em', genitive:'es'},
feminine: {nominative:'e', accusative:'e', dative:'er', genitive:'er'},
neuter: {nominative:'', accusative:'', dative:'em', genitive:'es'},
plural: {nominative:'e', accusative:'e', dative:'en', genitive:'er'},
};
export const combinePoss = (stem, ending) => (stem === 'euer' && ending) ? 'eur' + ending : stem + ending;
// ═══════════════════════════════════════════
// ADJECTIVE ENDINGS
// Three tables, chosen by what stands in front of the adjective. The idea the
// whole system rests on (the "one signal" principle American courses teach):
// the case/gender signal has to appear ONCE in the noun phrase. If the
// determiner already carries it, the adjective goes limp (-e/-en). If there is
// no determiner, the adjective has to carry it itself.
// ═══════════════════════════════════════════
// der-word endings: der/die/das, dieser, jeder, welcher, jener, alle.
// Note genitive masc/neut is -es here but -en on an adjective — the one place
// the two tables part company.
export const DER_ENDINGS = {
masculine:{nominative:'er',accusative:'en',dative:'em',genitive:'es'},
feminine: {nominative:'e', accusative:'e', dative:'er',genitive:'er'},
neuter: {nominative:'es',accusative:'es',dative:'em',genitive:'es'},
plural: {nominative:'e', accusative:'e', dative:'en',genitive:'er'},
};
const ADJ_GENDERS = ['masculine','feminine','neuter','plural'];
const ADJ_CASES = ['nominative','accusative','dative','genitive'];
// STRONG/WEAK already exist above as single signal letters; as adjective
// endings they are those letters with a leading -e where needed.
const ending = letter => (letter === 'e' ? 'e' : 'e' + letter);
export const ADJ_STRONG = Object.fromEntries(ADJ_GENDERS.map(g =>
[g, Object.fromEntries(ADJ_CASES.map(c => [c, ending(STRONG[g][c])]))]));
export const ADJ_WEAK = Object.fromEntries(ADJ_GENDERS.map(g =>
[g, Object.fromEntries(ADJ_CASES.map(c => [c, ending(WEAK[g][c])]))]));
// MIXED is not a third thing to memorise: it IS weak, except in the three
// slots where the ein-word itself has no ending (ein_ guter, ein_ gutes,
// ein_ gutes). There the adjective picks up the strong ending instead.
// Derived from POSS_ENDINGS so the rule is enforced, never duplicated.
export const ADJ_MIXED = Object.fromEntries(ADJ_GENDERS.map(g =>
[g, Object.fromEntries(ADJ_CASES.map(c =>
[c, POSS_ENDINGS[g][c] === '' ? ADJ_STRONG[g][c] : ADJ_WEAK[g][c]]))]));
export const ADJ_ENDINGS = { strong: ADJ_STRONG, weak: ADJ_WEAK, mixed: ADJ_MIXED };
export const ADJ_CLASS_LABEL = { weak:'Schwach', mixed:'Gemischt', strong:'Stark' };
export const ADJ_CLASS_HINT = {
weak: 'after a der-word — it already shows the case',
mixed: 'after an ein-word — sometimes it shows nothing',
strong:'no determiner — the adjective must show the case',
};
// Every distinct ending, so wrong answers are always plausible endings.
export const ADJ_ALL_ENDINGS = ['e','en','er','es','em'];
// ═══════════════════════════════════════════
// ADJECTIVES
// Adjectives declare which kinds of noun they may describe, the same
// discipline the preposition frames use — "ein grünes Kind" has to be
// impossible to build, not merely unlikely to come up.
// ═══════════════════════════════════════════
export const ADJ_NOUN_GROUPS = {
people: ['Mann','Frau','Arzt','Ärztin','Freund','Freundin','Lehrer','Lehrerin',
'Bruder','Schwester','Vater','Mutter','Tochter','Kind','Mädchen'],
animals: ['Hund','Katze'],
things: ['Buch','Auto','Tisch','Stuhl','Fenster','Tür','Bett','Sofa','Schrank',
'Regal','Tasche','Bild','Zug'],
// Buildings you can be inside; settlements you can live in; landscape you
// cannot ("Ich wohne in dem alten See" was reachable before this split).
buildings: ['Haus','Hotel','Museum','Büro','Restaurant','Kirche','Bibliothek',
'Supermarkt','Schule','Bahnhof','Zimmer','Küche','Keller','Wohnung'],
settlements:['Stadt','Dorf'],
landscape: ['Park','Garten','Wald','See','Fluss','Berg','Meer','Wiese','Brücke','Straße'],
mass: ['Wasser','Milch','Brot','Kaffee','Tee','Bier','Wein','Geld','Zeit','Musik','Arbeit'],
weather: ['Wetter','Regen'],
};
// groups = broad compatibility; nouns = an explicit list where the adjective is
// fussy and a whole group would let nonsense through.
// enPeople: German groß/klein mean tall/short of a person.
// neg: excluded from frames that assert liking ("I like the bad kitchen").
export const ADJECTIVES = [
{de:'groß', en:'big', enPeople:'tall', groups:['people','animals','things','buildings','settlements','landscape']},
{de:'klein', en:'small', enPeople:'short', groups:['people','animals','things','buildings','settlements','landscape']},
{de:'alt', en:'old', groups:['people','animals','things','buildings','settlements']},
{de:'neu', en:'new', groups:['things','buildings']},
{de:'jung', en:'young', groups:['people','animals']},
{de:'schön', en:'beautiful', groups:['things','buildings','settlements','landscape'], nouns:['Musik','Wetter']},
{de:'gut', en:'good', groups:['things','buildings','people','mass','weather']},
{de:'schlecht', en:'bad', neg:true, groups:['things','buildings','mass','weather']},
{de:'freundlich', en:'friendly', groups:['people','animals']},
{de:'müde', en:'tired', neg:true, groups:['people','animals']},
{de:'krank', en:'sick', neg:true, groups:['people','animals']},
{de:'interessant', en:'interesting', nouns:['Buch','Bild','Museum','Arbeit','Musik','Konzert']},
{de:'wichtig', en:'important', nouns:['Buch','Arbeit','Zeit','Geld','Konzert']},
{de:'billig', en:'cheap', nouns:['Auto','Buch','Tasche','Zug','Hotel','Restaurant','Kaffee','Bier','Wein','Bild']},
{de:'kalt', en:'cold', nouns:['Wasser','Milch','Kaffee','Tee','Bier','Wetter','Zimmer','Keller','Haus','Nacht']},
{de:'warm', en:'warm', nouns:['Wasser','Milch','Kaffee','Tee','Brot','Wetter','Zimmer','Haus']},
{de:'laut', en:'loud', neg:true, nouns:['Musik','Stadt','Straße','Restaurant','Hund','Konzert','Kind']},
{de:'ruhig', en:'quiet', nouns:['Zimmer','Dorf','Garten','Wald','Straße','Hund','Katze','Kind']},
{de:'lang', en:'long', nouns:['Straße','Brücke','Fluss','Buch','Nacht','Woche','Arbeit']},
{de:'rot', en:'red', nouns:['Auto','Buch','Tasche','Tür','Sofa','Bild','Wein','Katze']},
{de:'grün', en:'green', nouns:['Auto','Buch','Tasche','Tür','Sofa','Wiese','Wald','Tee']},
{de:'schwarz', en:'black', nouns:['Auto','Buch','Tasche','Tür','Sofa','Hund','Katze','Kaffee','Tee','Bild']},
{de:'weiß', en:'white', nouns:['Auto','Haus','Tür','Sofa','Tasche','Hund','Katze','Milch','Brot','Wein']},
{de:'blau', en:'blue', nouns:['Auto','Buch','Tasche','Tür','Sofa','See','Meer']},
];
export const adjNouns = adj => [...new Set([
...(adj.groups || []).flatMap(g => ADJ_NOUN_GROUPS[g]),
...(adj.nouns || []),
])];
const PEOPLE_SET = new Set(ADJ_NOUN_GROUPS.people);
export const adjEn = (adj, noun) => (adj.enPeople && PEOPLE_SET.has(noun.w)) ? adj.enPeople : adj.en;
export const DER_WORD_DETS = [
{stem:'d', en:'the', definite:true},
{stem:'dies', en:'this'},
{stem:'jed', en:'every'},
];
export const EIN_WORD_DETS = [
{stem:'ein', en:'a'},
{stem:'kein', en:'no'},
{stem:'mein', en:'my', poss:true},
{stem:'dein', en:'your', poss:true},
{stem:'sein', en:'his', poss:true},
{stem:'ihr', en:'her', poss:true},
{stem:'unser', en:'our', poss:true},
];
export function determinerFor(cls, det, g, cas) {
if (cls === 'strong') return '';
if (cls === 'weak') return det.definite ? DEF[g][cas] : det.stem + DER_ENDINGS[g][cas];
return combinePoss(det.stem, POSS_ENDINGS[g][cas]);
}
// Sentence carriers. `prep` is kept OUT of dePre so it can fuse with the
// article (in + dem -> im). deCap/enCap mark which sentence the noun phrase opens.
// noNeg drops adjectives that would contradict the frame ("I like the bad hotel").
export const ADJ_FRAMES = [
{cas:'nominative', dePre:'', dePost:' gefällt mir.', enPre:'I like ', enPost:'.', deCap:true, noNeg:true,
groups:['people','animals','things','buildings','settlements','landscape'],
nouns:['Musik','Wetter','Wein','Kaffee','Brot']},
{cas:'nominative', dePre:'', dePost:' ist da.', enPre:'', enPost:' is there.', deCap:true, enCap:true,
groups:['people','animals','things']},
{cas:'accusative', dePre:'Ich sehe ', dePost:'.', enPre:'I see ', enPost:'.',
groups:['people','animals','things','buildings','settlements','landscape']},
{cas:'accusative', dePre:'Ich kaufe ', dePost:'.', enPre:'I buy ', enPost:'.',
groups:['things'], nouns:['Brot','Wein','Bier','Kaffee','Tee','Milch']},
{cas:'accusative', dePre:'Ich mag ', dePost:'.', enPre:'I like ', enPost:'.', noNeg:true,
groups:['people','animals','things','buildings','settlements','landscape'],
nouns:['Musik','Kaffee','Tee','Wein','Bier','Brot']},
{cas:'dative', dePre:'Ich helfe ', dePost:'.', enPre:'I help ', enPost:'.',
groups:['people','animals']},
{cas:'dative', dePre:'Ich spiele ', prep:'mit', dePost:'.', enPre:'I play with ', enPost:'.',
groups:['animals']},
{cas:'dative', dePre:'Ich wohne ', prep:'in', dePost:'.', enPre:'I live in ', enPost:'.',
groups:['buildings','settlements']},
{cas:'dative', dePre:'Ich koche ', prep:'mit', dePost:'.', enPre:'I cook with ', enPost:'.',
nouns:['Wasser','Milch','Brot','Wein']},
{cas:'dative', dePre:'Ich warte ', prep:'vor', dePost:'.', enPre:'I wait in front of ', enPost:'.',
groups:['buildings']},
{cas:'genitive', dePre:'Ich arbeite ', prep:'trotz', dePost:'.', enPre:'I work despite ', enPost:'.',
nouns:['Wetter','Regen','Musik','Arbeit'], classes:['weak','strong']},
{cas:'genitive', dePre:'Ich bleibe ', prep:'wegen', dePost:'.', enPre:'I stay because of ', enPost:'.',
nouns:['Wetter','Regen','Musik'], classes:['weak','strong']},
];
ADJ_FRAMES.forEach((f, i) => { f.id = i; });
const MASS_LIKE = new Set([...ADJ_NOUN_GROUPS.mass, ...ADJ_NOUN_GROUPS.weather]);
const framePool = f => [...new Set([
...(f.groups || []).flatMap(g => ADJ_NOUN_GROUPS[g]),
...(f.nouns || []),
])];
export const CL = {nominative:'Nominativ',accusative:'Akkusativ',dative:'Dativ',genitive:'Genitiv',wechsel:'Wechsel'};
export const CASE_C = {nominative:'#2563eb',accusative:'#dc2626',dative:'#ea580c',genitive:'#16a34a',wechsel:'#7c3aed'};
export const GENDER_C = {masculine:'#16a34a',feminine:'#db2777',neuter:'#2563eb',plural:'#7c3aed'};
export const VERBS = {
sein: {de:{Er:'ist',Sie:'ist',Wir:'sind',Ich:'bin'}, en:{Er:'is',Sie:'is',Wir:'are',Ich:'am'}},
stehen: {de:{Er:'steht',Sie:'steht',Wir:'stehen',Ich:'stehe'}, en:{Er:'is standing',Sie:'is standing',Wir:'are standing',Ich:'am standing'}},
sitzen: {de:{Er:'sitzt',Sie:'sitzt',Wir:'sitzen',Ich:'sitze'}, en:{Er:'is sitting',Sie:'is sitting',Wir:'are sitting',Ich:'am sitting'}},
liegen: {de:{Er:'liegt',Sie:'liegt',Wir:'liegen',Ich:'liege'}, en:{Er:'is lying',Sie:'is lying',Wir:'are lying',Ich:'am lying'}},
warten: {de:{Er:'wartet',Sie:'wartet',Wir:'warten',Ich:'warte'},en:{Er:'waits',Sie:'waits',Wir:'wait',Ich:'wait'}},
wohnen: {de:{Er:'wohnt',Sie:'wohnt',Wir:'wohnen',Ich:'wohne'}, en:{Er:'lives',Sie:'lives',Wir:'live',Ich:'live'}},
gehen: {de:{Er:'geht',Sie:'geht',Wir:'gehen',Ich:'gehe'}, en:{Er:'walks',Sie:'walks',Wir:'walk',Ich:'walk'}},
laufen: {de:{Er:'läuft',Sie:'läuft',Wir:'laufen',Ich:'laufe'}, en:{Er:'runs',Sie:'runs',Wir:'run',Ich:'run'}},
fahren: {de:{Er:'fährt',Sie:'fährt',Wir:'fahren',Ich:'fahre'}, en:{Er:'drives',Sie:'drives',Wir:'drive',Ich:'drive'}},
kommen: {de:{Er:'kommt',Sie:'kommt',Wir:'kommen',Ich:'komme'}, en:{Er:'comes',Sie:'comes',Wir:'come',Ich:'come'}},
reisen: {de:{Er:'reist',Sie:'reist',Wir:'reisen',Ich:'reise'}, en:{Er:'travels',Sie:'travels',Wir:'travel',Ich:'travel'}},
kaufen: {de:{Er:'kauft',Sie:'kauft',Wir:'kaufen',Ich:'kaufe'}, en:{Er:'buys',Sie:'buys',Wir:'buy',Ich:'buy'}},
sprechen:{de:{Er:'spricht',Sie:'spricht',Wir:'sprechen',Ich:'spreche'}, en:{Er:'talks',Sie:'talks',Wir:'talk',Ich:'talk'}},
spielen: {de:{Er:'spielt',Sie:'spielt',Wir:'spielen',Ich:'spiele'}, en:{Er:'plays',Sie:'plays',Wir:'play',Ich:'play'}},
schlafen:{de:{Er:'schläft',Sie:'schläft',Wir:'schlafen',Ich:'schlafe'}, en:{Er:'sleeps',Sie:'sleeps',Wir:'sleep',Ich:'sleep'}},
bleiben: {de:{Er:'bleibt',Sie:'bleibt',Wir:'bleiben',Ich:'bleibe'}, en:{Er:'stays',Sie:'stays',Wir:'stay',Ich:'stay'}},
nehmen: {de:{Er:'nimmt',Sie:'nimmt',Wir:'nehmen',Ich:'nehme'}, en:{Er:'takes',Sie:'takes',Wir:'take',Ich:'take'}},
arbeiten:{de:{Er:'arbeitet',Sie:'arbeitet',Wir:'arbeiten',Ich:'arbeite'}, en:{Er:'works',Sie:'works',Wir:'work',Ich:'work'}},
springen:{de:{Er:'springt',Sie:'springt',Wir:'springen',Ich:'springe'}, en:{Er:'jumps',Sie:'jumps',Wir:'jump',Ich:'jump'}},
hängen: {de:{Er:'hängt',Sie:'hängt',Wir:'hängen',Ich:'hänge'}, en:{Er:'hangs',Sie:'hangs',Wir:'hang',Ich:'hang'}},
legen: {de:{Er:'legt',Sie:'legt',Wir:'legen',Ich:'lege'}, en:{Er:'puts',Sie:'puts',Wir:'put',Ich:'put'}},
stellen: {de:{Er:'stellt',Sie:'stellt',Wir:'stellen',Ich:'stelle'}, en:{Er:'puts',Sie:'puts',Wir:'put',Ich:'put'}},
rennen: {de:{Er:'rennt',Sie:'rennt',Wir:'rennen',Ich:'renne'}, en:{Er:'runs',Sie:'runs',Wir:'run',Ich:'run'}},
klettern:{de:{Er:'klettert',Sie:'klettert',Wir:'klettern',Ich:'klettere'}, en:{Er:'climbs',Sie:'climbs',Wir:'climb',Ich:'climb'}},
fliegen: {de:{Er:'fliegt',Sie:'fliegt',Wir:'fliegen',Ich:'fliege'}, en:{Er:'flies',Sie:'flies',Wir:'fly',Ich:'fly'}},
schwimmen:{de:{Er:'schwimmt',Sie:'schwimmt',Wir:'schwimmen',Ich:'schwimme'}, en:{Er:'swims',Sie:'swims',Wir:'swim',Ich:'swim'}},
steigen: {de:{Er:'steigt',Sie:'steigt',Wir:'steigen',Ich:'steige'}, en:{Er:'climbs',Sie:'climbs',Wir:'climb',Ich:'climb'}},
werfen: {de:{Er:'wirft',Sie:'wirft',Wir:'werfen',Ich:'werfe'}, en:{Er:'throws',Sie:'throws',Wir:'throw',Ich:'throw'}},
stecken: {de:{Er:'steckt',Sie:'steckt',Wir:'stecken',Ich:'stecke'}, en:{Er:'puts',Sie:'puts',Wir:'put',Ich:'put'}},
setzen: {de:{Er:'setzt',Sie:'setzt',Wir:'setzen',Ich:'setze'}, en:{Er:'sits',Sie:'sits',Wir:'sit',Ich:'sit'}},
kleben: {de:{Er:'klebt',Sie:'klebt',Wir:'kleben',Ich:'klebe'}, en:{Er:'sticks',Sie:'sticks',Wir:'stick',Ich:'stick'}},
schreiben:{de:{Er:'schreibt',Sie:'schreibt',Wir:'schreiben',Ich:'schreibe'}, en:{Er:'writes',Sie:'writes',Wir:'write',Ich:'write'}},
fallen: {de:{Er:'fällt',Sie:'fällt',Wir:'fallen',Ich:'falle'}, en:{Er:'falls',Sie:'falls',Wir:'fall',Ich:'fall'}},
ziehen: {de:{Er:'zieht',Sie:'zieht',Wir:'ziehen',Ich:'ziehe'}, en:{Er:'moves',Sie:'moves',Wir:'move',Ich:'move'}},
tragen: {de:{Er:'trägt',Sie:'trägt',Wir:'tragen',Ich:'trage'}, en:{Er:'carries',Sie:'carries',Wir:'carry',Ich:'carry'}},
schieben:{de:{Er:'schiebt',Sie:'schiebt',Wir:'schieben',Ich:'schiebe'}, en:{Er:'pushes',Sie:'pushes',Wir:'push',Ich:'push'}},
packen: {de:{Er:'packt',Sie:'packt',Wir:'packen',Ich:'packe'}, en:{Er:'packs',Sie:'packs',Wir:'pack',Ich:'pack'}},
tanzen: {de:{Er:'tanzt',Sie:'tanzt',Wir:'tanzen',Ich:'tanze'}, en:{Er:'is dancing',Sie:'is dancing',Wir:'are dancing',Ich:'am dancing'}},
leben: {de:{Er:'lebt',Sie:'lebt',Wir:'leben',Ich:'lebe'}, en:{Er:'lives',Sie:'lives',Wir:'live',Ich:'live'}},
ankommen:{de:{Er:'kommt',Sie:'kommt',Wir:'kommen',Ich:'komme'}, en:{Er:'arrives',Sie:'arrives',Wir:'arrive',Ich:'arrive'}},
};
export const SUBJ_EN = {Er:'He',Sie:'She',Wir:'We',Ich:'I'};
function mkv(stems) {
return {
ich: stems.ich, du: stems.du,
er: stems.er, sie_sg: stems.er, es: stems.er,
wir: stems.wir, sie_pl: stems.wir, Sie_sg: stems.wir, Sie_pl: stems.wir,
ihr: stems.ihr,
};
}
export const VC = {
kommen: {de:mkv({ich:'komme',du:'kommst',er:'kommt',wir:'kommen',ihr:'kommt'}), en:mkv({ich:'come',du:'come',er:'comes',wir:'come',ihr:'come'})},
sein: {de:mkv({ich:'bin',du:'bist',er:'ist',wir:'sind',ihr:'seid'}), en:mkv({ich:'am',du:'are',er:'is',wir:'are',ihr:'are'})},
haben: {de:mkv({ich:'habe',du:'hast',er:'hat',wir:'haben',ihr:'habt'}), en:mkv({ich:'have',du:'have',er:'has',wir:'have',ihr:'have'})},
sehen: {de:mkv({ich:'sehe',du:'siehst',er:'sieht',wir:'sehen',ihr:'seht'}), en:mkv({ich:'see',du:'see',er:'sees',wir:'see',ihr:'see'})},
kennen: {de:mkv({ich:'kenne',du:'kennst',er:'kennt',wir:'kennen',ihr:'kennt'}), en:mkv({ich:'know',du:'know',er:'knows',wir:'know',ihr:'know'})},
lieben: {de:mkv({ich:'liebe',du:'liebst',er:'liebt',wir:'lieben',ihr:'liebt'}), en:mkv({ich:'love',du:'love',er:'loves',wir:'love',ihr:'love'})},
helfen: {de:mkv({ich:'helfe',du:'hilfst',er:'hilft',wir:'helfen',ihr:'helft'}), en:mkv({ich:'help',du:'help',er:'helps',wir:'help',ihr:'help'})},
geben: {de:mkv({ich:'gebe',du:'gibst',er:'gibt',wir:'geben',ihr:'gebt'}), en:mkv({ich:'give',du:'give',er:'gives',wir:'give',ihr:'give'})},
};
export const SG_PRONOUN_KEYS = ['ich','du','er','sie_sg','es'];
export const PL_PRONOUN_KEYS = ['wir','ihr','sie_pl'];
export const FORMAL_PRONOUN_KEYS = ['Sie_sg','Sie_pl'];
export const ALL_PRONOUN_KEYS = PRONOUNS.map(p=>p.key);
const SAFE_SUBJECT_KEYS = ['ich','du','er','wir','ihr'];
export const LEVELS = [
{id:2, tab:'article', name:'Akkusativ', desc:'durch, für, gegen, ohne, um', cases:['accusative'], preps:SENTENCE_ACC_PREPS, type:'article', artType:'definite'},
{id:3, tab:'article', name:'Dativ', desc:'mit, von, aus, bei, nach, seit...', cases:['dative'], preps:SENTENCE_DAT_PREPS, type:'article', artType:'definite'},
{id:4, tab:'article', name:'Wechselpräpositionen', desc:'Two-way: motion (Akk) vs location (Dat)', cases:['accusative','dative'], preps:PREPS_BY_CASE.wechsel, type:'article', artType:'definite', twoWay:true},
{id:4.5, tab:'article', name:'Kontraktionen', desc:'Fused forms in context: ins, am, zum…', cases:['accusative','dative'], type:'chunk', artType:'definite'},
{id:5, tab:'article', name:'Genitiv', desc:'trotz, während, wegen, statt...', cases:['genitive'], preps:PREPS_BY_CASE.genitive, type:'article', artType:'definite'},
{id:6, tab:'article', name:'Alles gemischt', desc:'All prepositions, definite articles', cases:['accusative','dative','genitive'], preps:[...SENTENCE_ACC_PREPS,...PREPS_BY_CASE.dative,...PREPS_BY_CASE.genitive,...PREPS_BY_CASE.wechsel], type:'article', artType:'definite', mixed:true},
{id:7, tab:'article', name:'ein-Wörter', desc:'Everything with ein/eine/einem...', cases:['accusative','dative','genitive'], preps:[...SENTENCE_ACC_PREPS,...PREPS_BY_CASE.dative,...INDEF_GEN_PREPS,...PREPS_BY_CASE.wechsel], type:'article', artType:'indefinite', mixed:true},
// Daily Practice pseudo-levels (one per tab): a one-tap drill that mixes
// questions from every lesson in the tab. Rendered pinned at the top of each
// tab by App.jsx (not inline in the level list) and locked until every
// lesson in the tab has been passed.
{id:7.5, tab:'article', name:'Daily Practice', desc:'Every article lesson, mixed · one-tap', daily:true},
{id:8, tab:'prep', name:'Häufige Präpositionen', desc:'16 most common prepositions', preps:COMMON_PREPS, type:'prep'},
{id:9, tab:'prep', name:'Seltene Präpositionen', desc:'15 less common prepositions', preps:RARE_PREPS, type:'prep'},
{id:10, tab:'prep', name:'Alle Präpositionen', desc:'All 31 prepositions mixed', preps:ALL_PREPS, type:'prep'},
{id:10.5, tab:'prep', name:'Daily Practice', desc:'Every preposition lesson, mixed · one-tap', daily:true},
{id:18, tab:'prep', name:'Wechsel im Kontext', desc:'Real sentences: Akkusativ or Dativ?', preps:PREPS_BY_CASE.wechsel, type:'wechselContext'},
{id:19, tab:'prep', name:'Kontraktionen', desc:'Contractions: am, ins, zum, beim…', type:'contraction'},
{id:11, tab:'poss', name:'Häufige Possessive', desc:'mein · dein · sein — Akkusativ & Dativ', possessives:['mein','dein','sein'], cases:['accusative','dative'], type:'poss'},
{id:12, tab:'poss', name:'Schwierige Possessive', desc:'ihr · unser · euer — Akkusativ & Dativ', possessives:['ihr','unser','euer'], cases:['accusative','dative'], type:'poss'},
{id:13, tab:'poss', name:'Alle gemischt', desc:'mein, dein, sein, ihr, unser, euer — incl. Genitiv', possessives:['mein','dein','sein','ihr','unser','euer'], cases:['accusative','dative','genitive'], type:'poss'},
{id:13.5, tab:'poss', name:'Daily Practice', desc:'Every possessive lesson, mixed · one-tap', daily:true},
{id:14, tab:'pronoun', name:'Singular Pronomen', desc:'mich, mir, dich, dir…', pronouns:SG_PRONOUN_KEYS, cases:['accusative','dative'], type:'pronoun'},
{id:15, tab:'pronoun', name:'Plural Pronomen', desc:'uns, euch, ihnen…', pronouns:PL_PRONOUN_KEYS, cases:['accusative','dative'], type:'pronoun'},
{id:16, tab:'pronoun', name:'Sie (formal)', desc:'Formal Sie — singular and plural', pronouns:FORMAL_PRONOUN_KEYS, cases:['accusative','dative'], type:'pronoun'},
{id:17, tab:'pronoun', name:'Alle gemischt', desc:'All 10 pronouns, Akkusativ & Dativ', pronouns:ALL_PRONOUN_KEYS, cases:['accusative','dative'], type:'pronoun'},
{id:17.5, tab:'pronoun', name:'Daily Practice', desc:'Every pronoun lesson, mixed · one-tap', daily:true},
// Adjective endings. Nominative IS tested here, unlike everywhere else in the
// app: der gute Mann vs ein guter Mann is the clearest illustration of the
// whole weak/mixed split, and skipping it would gut the section.
{id:20, tab:'adj', name:'Schwach', desc:'After der, diese, jede — only -e and -en', classes:['weak'], cases:['nominative','accusative','dative'], type:'adjective'},
{id:21, tab:'adj', name:'Gemischt', desc:'After ein, kein, mein — weak plus three exceptions', classes:['mixed'], cases:['nominative','accusative','dative'], type:'adjective'},
{id:22, tab:'adj', name:'Stark', desc:'No determiner — the adjective carries the case', classes:['strong'], cases:['nominative','accusative','dative'], type:'adjective'},
{id:23, tab:'adj', name:'Welche Tabelle?', desc:'der-word, ein-word or nothing at all?', classes:['weak','mixed','strong'], cases:['nominative','accusative','dative'], type:'adjClass'},
{id:24, tab:'adj', name:'Nach Präposition', desc:'The full chain: preposition → case → table', classes:['weak','mixed','strong'], cases:['accusative','dative','genitive'], type:'adjective', prepOnly:true},
{id:25, tab:'adj', name:'Alles gemischt', desc:'All three tables, including Genitiv', classes:['weak','mixed','strong'], cases:['nominative','accusative','dative','genitive'], type:'adjective'},
{id:25.5, tab:'adj', name:'Daily Practice', desc:'Every adjective lesson, mixed · one-tap', daily:true},
];
// Contractions (preposition + article). pref:true = preferred even in writing.
export const CONTRACTIONS = [
{prep:'an', art:'das', form:'ans', pref:true, c:'wechsel'},
{prep:'an', art:'dem', form:'am', pref:true, c:'wechsel'},
{prep:'in', art:'das', form:'ins', pref:true, c:'wechsel'},
{prep:'in', art:'dem', form:'im', pref:true, c:'wechsel'},
{prep:'auf',art:'das', form:'aufs',pref:true, c:'wechsel'},
{prep:'bei',art:'dem', form:'beim',pref:true, c:'dative'},
{prep:'von',art:'dem', form:'vom', pref:true, c:'dative'},
{prep:'zu', art:'dem', form:'zum', pref:true, c:'dative'},
{prep:'zu', art:'der', form:'zur', pref:true, c:'dative'},
{prep:'durch',art:'das',form:'durchs',pref:false,c:'accusative'},
{prep:'für',art:'das', form:'fürs',pref:false, c:'accusative'},
{prep:'um', art:'das', form:'ums', pref:false, c:'accusative'},
{prep:'über',art:'das',form:'übers',pref:false,c:'wechsel'},
{prep:'unter',art:'das',form:'unters',pref:false,c:'wechsel'},
{prep:'vor',art:'das', form:'vors',pref:false, c:'wechsel'},
{prep:'hinter',art:'das',form:'hinters',pref:false,c:'wechsel'},
];
export const CONTRACTABLE = {};
CONTRACTIONS.filter(c=>c.pref).forEach(c => { (CONTRACTABLE[c.prep] = CONTRACTABLE[c.prep] || {})[c.art] = c.form; });
export const CHUNK_PREPS = new Set(Object.keys(CONTRACTABLE));
export const ALL_GENDERS = ['masculine','feminine','neuter','plural'];
// Genders whose definite article does NOT fuse with this preposition. German
// all but requires zum/zur/im/ins/am/ans/beim/vom, so a fill-the-article
// sentence must never ask for the two-word form of one of those — it would be
// teaching a phrase no German says. Those pairings live in the fused
// "Kontraktionen" questions instead, which is where they belong.
export function nonFusingGenders(prepKey, cas) {
return ALL_GENDERS.filter(g => !CONTRACTABLE[prepKey]?.[DEF[g][cas]]);
}
// an, in, auf, bei, von, zu
export const WECHSEL_SET = new Set(PREPS_BY_CASE.wechsel);
// ═══════════════════════════════════════════
// SENTENCE FRAMES
// Per-preposition verb/noun pairings so generated sentences are
// semantically plausible ("Er kauft das Geschenk für den Vater",
// not "Wir reisen für den Hund").
//
// frame: {verbs, nouns, mid?, fixedSubj?, enPrep?, enDet?, post?, enVerb?, poss?}
// mid — object between verb and preposition ({de,en})
// fixedSubj — literal subject instead of Er/Sie/Wir/Ich
// ({de, en, vkey?, enVkey?} — conjugation keys, default 'Er')
// enPrep — English preposition override (default PREPS[p].en or m/l)
// enDet — English determiner (default 'the'; '' drops it)
// post — trailing text after the noun ({accusative, dative, genitive, en})
// enVerb — English verb override map (Er/Sie/Wir/Ich)
// poss — false: skip this frame for possessive questions
// Wechsel preps use {motion:[...], location:[...]} instead of a flat list.
// ═══════════════════════════════════════════
const ZW_POST = {accusative:' und das Fenster', dative:' und dem Fenster', en:' and the window'};
export const PREP_FRAMES = {
durch: [{verbs:['gehen','laufen','fahren'], nouns:['Park','Stadt','Straße','Garten','Tür']}],
für: [{verbs:['kaufen'], mid:{de:'das Geschenk',en:'the gift'}, nouns:[...PERSONS,'Hund','Katze']}],
gegen: [{verbs:['fahren'], nouns:['Baum','Haus','Tür','Fenster','Auto'], enPrep:'into'}],
ohne: [{verbs:['kommen','reisen','gehen'], nouns:[...PERSONS,'Hund','Katze','Buch','Auto']}],
um: [{verbs:['laufen','gehen'], nouns:['Tisch','Haus','Baum','Park','Garten']}],
aus: [{verbs:['kommen','laufen'], nouns:['Haus','Schule','Büro','Museum','Hotel','Stadt','Garten','Restaurant']}],
bei: [
{verbs:['wohnen','sein','warten'], nouns:VISITABLE, enPossS:true},
{verbs:['wohnen','sein','warten'], nouns:PERSONS, enPossS:true, possOnly:true},
],
mit: [
{verbs:['sprechen'], nouns:PERSONS},
{verbs:['spielen'], nouns:['Hund','Katze','Kind']},
{verbs:['fahren'], nouns:['Auto','Zug']},
],
nach: [{verbs:['kommen'], nouns:['Schule','Arbeit','Nacht','Woche'], enPrep:'after', enDet:'', poss:false,
indefNouns:['Woche','Nacht']}],
seit: [{verbs:['wohnen'], mid:{de:'hier',en:'here'}, nouns:['Nacht','Woche'], enPrep:'since', enDet:'that',
enVerb:{Er:'has lived',Sie:'has lived',Wir:'have lived',Ich:'have lived'}, poss:false,
// "seit einer Woche" is "FOR a week" in English, not "since a week".
indefNouns:['Woche'], enPrepIndef:'for'}],
von: [
{verbs:['kommen'], nouns:VISITABLE, enPossS:true},
{verbs:['kommen'], nouns:['Arbeit','Schule','Büro']},
{verbs:['kommen'], nouns:PERSONS, enPossS:true, possOnly:true},
],
zu: [
{verbs:['gehen','fahren'], nouns:VISITABLE, enPossS:true},
{verbs:['gehen','fahren'], nouns:['Schule','Park','Museum','Büro','Restaurant','Hotel']},
{verbs:['gehen','fahren'], nouns:PERSONS, enPossS:true, possOnly:true},
],
außer: [{fixedSubj:{de:'Alle',en:'Everyone',vkey:'Wir',enVkey:'Er'}, verbs:['kommen'], nouns:PERSONS, enPrep:'except'}],
gegenüber: [{verbs:['wohnen'], nouns:['Schule','Park','Museum','Hotel','Restaurant']}],
trotz: [{verbs:['schlafen','arbeiten'], nouns:['Musik','Regen','Wetter'], poss:false}],
während: [
{verbs:['schlafen'], nouns:['Nacht','Konzert'], poss:false},
{verbs:['arbeiten'], nouns:['Woche','Nacht'], poss:false},
],
wegen: [{verbs:['bleiben'], nouns:['Regen','Wetter','Kind','Hund','Katze']}],
statt: [{verbs:['nehmen'], mid:{de:'den Bus',en:'the bus'}, nouns:['Auto','Zug']}],
anstatt: [{verbs:['nehmen'], mid:{de:'den Bus',en:'the bus'}, nouns:['Auto','Zug']}],
außerhalb: [{verbs:['wohnen','arbeiten'], nouns:['Stadt']}],
innerhalb: [{verbs:['wohnen','arbeiten'], nouns:['Stadt']}],
// ── Wechselpräpositionen ────────────────────────────────────
// Split by what DECIDES the case, not by "is there movement?".
// motion → the phrase is the goal: the thing wasn't there, now it is.
// location → the phrase is the arena: the thing was already there.
// Motion verbs deliberately appear on the DATIVE side too (schwimmen im See,
// tanzen in der Küche, spielen auf der Straße) — that is the whole lesson.
// Every frame is one-way: no verb+noun pairing may license both readings,
// because level 18 blanks the article and the learner has only the verb.
an: {
motion: [
{verbs:['gehen','laufen'], nouns:['Tür','Fenster','Tafel']},
{verbs:['hängen'], mid:{de:'das Bild',en:'the picture'}, nouns:['Wand','Tür'], enPrep:'on'},
{verbs:['schreiben'], mid:{de:'das Wort',en:'the word'}, nouns:['Tafel','Wand'], enPrep:'on'},
{verbs:['kleben'], mid:{de:'den Zettel',en:'the note'}, nouns:['Tür','Fenster','Schrank'], enPrep:'on'},
{verbs:['stellen'], mid:{de:'den Stuhl',en:'the chair'}, nouns:['Fenster','Wand','Tür'], enPrep:'against'},
{verbs:['fahren'], nouns:['See','Fluss','Meer']},
],
location: [
{verbs:['stehen','warten','sitzen'], nouns:['Tür','Fenster','Tafel']},
{verbs:['hängen'], fixedSubj:{de:'Das Bild',en:'The picture'}, nouns:['Wand','Tür'], enPrep:'on', enVerb:{Er:'is hanging',Sie:'is hanging',Wir:'are hanging',Ich:'am hanging'}},
{verbs:['wohnen','leben'], nouns:['See','Fluss'], enPrep:'by'},
{verbs:['arbeiten'], nouns:['Tisch','Fenster']},
{verbs:['spielen','schlafen'], nouns:['See','Fluss','Meer'], enPrep:'by'},
// ankommen takes DATIVE although you obviously arrived — the an- prefix
// already carries the arrival, so the phrase only locates it.
{verbs:['ankommen'], nouns:['Bahnhof','Flughafen'], post:{dative:' an', en:''}},
],
},
auf: {
motion: [
{verbs:['legen'], mid:{de:'das Buch',en:'the book'}, nouns:['Tisch','Stuhl','Regal','Bett','Sofa','Boden']},
{verbs:['stellen'], mid:{de:'die Lampe',en:'the lamp'}, nouns:['Tisch','Regal','Boden','Schrank']},
{verbs:['setzen'], mid:{de:'das Kind',en:'the child'}, nouns:['Stuhl','Sofa','Bank','Bett']},
{verbs:['werfen'], mid:{de:'den Ball',en:'the ball'}, nouns:['Dach','Boden','Bett','Sofa']},
{verbs:['klettern','steigen'], nouns:['Berg','Baum','Dach','Schrank']},
{verbs:['springen'], fixedSubj:{de:'Die Katze',en:'The cat'}, nouns:['Tisch','Dach','Sofa','Schrank']},
{verbs:['gehen','laufen'], nouns:['Straße','Wiese','Brücke']},
],
location: [
{verbs:['sitzen'], nouns:['Stuhl','Sofa','Bank','Boden','Bett']},
{verbs:['liegen'], fixedSubj:{de:'Das Buch',en:'The book'}, nouns:['Tisch','Bett','Regal','Boden','Sofa']},
{verbs:['stehen','warten'], nouns:['Straße','Dach','Berg','Brücke','Wiese']},
{verbs:['spielen','tanzen'], nouns:['Straße','Wiese','Boden','Brücke']},
{verbs:['schlafen'], nouns:['Sofa','Boden','Bett']},
{verbs:['arbeiten'], nouns:['Dach','Wiese','Straße']},
],
},
hinter: {
motion: [
{verbs:['gehen','laufen','fahren'], nouns:['Haus','Baum','Garten','Schule','Berg','Kirche']},
{verbs:['stellen'], mid:{de:'den Stuhl',en:'the chair'}, nouns:['Tisch','Tür','Schrank','Sofa']},
{verbs:['werfen'], mid:{de:'den Ball',en:'the ball'}, nouns:['Haus','Baum','Schrank','Sofa']},
{verbs:['legen'], mid:{de:'die Tasche',en:'the bag'}, nouns:['Tür','Schrank','Sofa']},
],
location: [
{verbs:['stehen','warten'], nouns:['Haus','Baum','Tür','Schule','Kirche','Bahnhof']},
{verbs:['liegen'], fixedSubj:{de:'Der Ball',en:'The ball'}, nouns:['Schrank','Sofa','Baum','Haus']},
{verbs:['spielen'], nouns:['Haus','Schule','Garten','Kirche']},
{verbs:['sitzen'], nouns:['Tisch','Baum','Schrank']},
{verbs:['wohnen','leben'], nouns:['Bahnhof','Kirche','Supermarkt','Wald']},
],
},
in: {
motion: [
// places you can drive into vs places you only enter on foot
{verbs:['gehen','fahren','kommen'], nouns:['Park','Garten','Stadt','Schule','Haus','Hotel','Museum','Büro','Restaurant','Wald','Dorf']},
{verbs:['gehen','laufen','rennen','kommen'], nouns:['Küche','Zimmer','Keller','Kirche','Bibliothek','Supermarkt','Haus','Schule','Museum','Restaurant','Büro','Garten']},
{verbs:['springen','fallen'], nouns:['See','Fluss','Wasser','Meer']},
{verbs:['legen'], mid:{de:'das Buch',en:'the book'}, nouns:['Tasche','Schrank','Regal']},
{verbs:['stecken'], mid:{de:'den Schlüssel',en:'the key'}, nouns:['Tasche','Tür','Schrank']},
{verbs:['packen'], mid:{de:'das Buch',en:'the book'}, nouns:['Tasche','Schrank']},
{verbs:['tragen'], mid:{de:'die Tasche',en:'the bag'}, nouns:['Küche','Zimmer','Keller','Haus']},
{verbs:['ziehen'], nouns:['Wohnung','Stadt','Dorf','Haus']},
],
location: [
{verbs:['wohnen','leben'], nouns:['Stadt','Haus','Hotel','Dorf','Wohnung','Wald']},
{verbs:['sein','warten','sitzen','arbeiten'], nouns:[...PLACES_IN,'Kirche','Bibliothek','Supermarkt','Küche','Zimmer','Keller']},
{verbs:['schwimmen'], nouns:['See','Fluss','Meer','Wasser']},
{verbs:['tanzen','spielen'], nouns:['Küche','Zimmer','Garten','Wohnung','Wald']},
{verbs:['schlafen'], nouns:['Bett','Zimmer','Hotel','Keller']},
{verbs:['liegen'], fixedSubj:{de:'Das Buch',en:'The book'}, nouns:['Tasche','Schrank','Regal']},
{verbs:['stecken'], fixedSubj:{de:'Der Schlüssel',en:'The key'}, nouns:['Tür','Tasche','Schrank'], enVerb:{Er:'is stuck',Sie:'is stuck',Wir:'are stuck',Ich:'am stuck'}},
{verbs:['ankommen'], nouns:['Stadt','Hotel','Dorf'], post:{dative:' an', en:''}},
],
},
neben: {
motion: [
{verbs:['stellen'], mid:{de:'die Lampe',en:'the lamp'}, nouns:['Tisch','Stuhl','Tür','Bett','Sofa','Schrank']},
{verbs:['legen'], mid:{de:'das Buch',en:'the book'}, nouns:['Stuhl','Tisch','Bett','Sofa']},
{verbs:['setzen'], mid:{de:'das Kind',en:'the child'}, nouns:['Fenster','Tür','Schrank']},
{verbs:['schieben'], mid:{de:'den Stuhl',en:'the chair'}, nouns:['Tisch','Bett','Regal']},
],
location: [
{verbs:['stehen','sitzen'], nouns:['Tisch','Tür','Fenster','Bett','Schrank','Sofa']},
{verbs:['wohnen','leben'], nouns:['Schule','Park','Museum','Kirche','Bahnhof','Supermarkt','Bibliothek']},
{verbs:['liegen'], fixedSubj:{de:'Das Buch',en:'The book'}, nouns:['Bett','Sofa','Tisch','Regal']},
{verbs:['warten'], nouns:['Tür','Bahnhof','Kirche']},
{verbs:['arbeiten'], nouns:['Fenster','Tür']},
],
},
über: {
motion: [
{verbs:['gehen','laufen','fahren','rennen'], nouns:['Straße','Brücke','Wiese']},
{verbs:['hängen'], mid:{de:'die Lampe',en:'the lamp'}, nouns:['Tisch','Bett','Sofa']},
{verbs:['fliegen'], nouns:['Stadt','Berg','Meer','Wald','See']},
{verbs:['werfen'], mid:{de:'den Ball',en:'the ball'}, nouns:['Wand','Brücke','Schrank']},
],
location: [
{verbs:['hängen'], fixedSubj:{de:'Die Lampe',en:'The lamp'}, nouns:['Tisch','Bett','Sofa'], enVerb:{Er:'is hanging',Sie:'is hanging',Wir:'are hanging',Ich:'am hanging'}},
{verbs:['wohnen','leben'], nouns:['Restaurant','Supermarkt','Büro','Bibliothek']},
{verbs:['arbeiten'], nouns:['Restaurant','Büro','Supermarkt']},
],
},
unter: {
motion: [
{verbs:['legen'], mid:{de:'das Buch',en:'the book'}, nouns:['Tisch','Stuhl','Bett','Sofa','Regal']},
{verbs:['stellen'], mid:{de:'die Tasche',en:'the bag'}, nouns:['Tisch','Bett','Stuhl','Regal']},
{verbs:['werfen'], mid:{de:'den Ball',en:'the ball'}, nouns:['Sofa','Bett','Tisch','Schrank']},
{verbs:['schieben'], mid:{de:'die Tasche',en:'the bag'}, nouns:['Bett','Sofa','Tisch']},
{verbs:['laufen','rennen'], fixedSubj:{de:'Die Katze',en:'The cat'}, nouns:['Tisch','Baum','Bett','Sofa']},
],
location: [
{verbs:['sitzen','stehen','schlafen'], nouns:['Baum','Brücke','Dach']},
{verbs:['liegen'], fixedSubj:{de:'Das Buch',en:'The book'}, nouns:['Bett','Sofa','Tisch','Stuhl','Regal']},
{verbs:['spielen','tanzen'], nouns:['Baum','Brücke','Dach']},
{verbs:['warten'], nouns:['Brücke','Baum','Dach']},
{verbs:['wohnen'], nouns:['Dach']},
],
},
vor: {
motion: [
{verbs:['fahren','gehen','laufen'], nouns:['Haus','Tür','Schule','Hotel','Kirche','Bahnhof','Supermarkt']},
{verbs:['stellen'], mid:{de:'den Stuhl',en:'the chair'}, nouns:['Fenster','Tür','Schrank','Regal']},
{verbs:['legen'], mid:{de:'die Tasche',en:'the bag'}, nouns:['Tür','Bett','Sofa']},
],
location: [
{verbs:['stehen','warten'], nouns:['Tür','Haus','Schule','Hotel','Museum','Restaurant','Kirche','Bahnhof','Supermarkt','Bibliothek']},
{verbs:['sitzen'], nouns:['Fenster','Haus','Tür']},
{verbs:['spielen'], nouns:['Haus','Schule','Kirche','Garten']},
{verbs:['tanzen'], nouns:['Haus','Kirche','Hotel']},
{verbs:['liegen'], fixedSubj:{de:'Die Tasche',en:'The bag'}, nouns:['Tür','Bett','Sofa','Schrank']},
],
},
zwischen: {
motion: [
{verbs:['stellen'], mid:{de:'die Lampe',en:'the lamp'}, nouns:['Tisch','Stuhl','Sofa','Schrank'], post:ZW_POST},
{verbs:['legen'], mid:{de:'das Buch',en:'the book'}, nouns:['Tisch','Stuhl','Bett'], post:ZW_POST},
{verbs:['setzen'], mid:{de:'das Kind',en:'the child'}, nouns:['Stuhl','Sofa'], post:ZW_POST},
],
location: [
{verbs:['stehen','sitzen'], nouns:['Tisch','Stuhl','Sofa','Schrank'], post:ZW_POST},
{verbs:['liegen'], fixedSubj:{de:'Das Buch',en:'The book'}, nouns:['Tisch','Stuhl','Regal'], post:ZW_POST},
{verbs:['warten'], nouns:['Tisch','Schrank'], post:ZW_POST},
],
},
};
// ═══════════════════════════════════════════
// RANDOM HELPERS + SESSION STATE
// ═══════════════════════════════════════════
export const pick = a => a[Math.floor(Math.random()*a.length)];
export const cap = s => s.charAt(0).toUpperCase() + s.slice(1);
const pickAvoid = (arr, avoid) => {
if (!arr.length) return undefined;
if (arr.length === 1) return arr[0];
const filtered = arr.filter(x => x !== avoid);
return pick(filtered.length ? filtered : arr);
};
const shuffle = a => {
const r = [...a];
for (let i = r.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[r[i], r[j]] = [r[j], r[i]];
}
return r;
};
// Shuffle-bag: deal every item from the pool once before any repeats,
// and never let a refill produce the same item twice in a row.
function drawBag(state, key, pool, avoid) {
let bag = state.bags[key];
if (!bag || !bag.length) {
bag = shuffle(pool);
if (bag.length > 1 && bag[bag.length - 1] === avoid) {
bag.pop();
bag.unshift(avoid);
}
state.bags[key] = bag;
}
return bag.pop();
}
export function newState() {
return { bags:{}, last:{ prep:null, verb:null, noun:null, subj:null } };
}
// A practice session: one shuffle-bag + anti-repeat memory per round.
// Daily levels mix questions from every lesson in their tab: a shuffle-bag
// deals the lesson ids so no lesson repeats back-to-back, and each lesson
// keeps its own state (their bag pools differ, so they can't share one).
export function createSession(levelId) {
const lv = LEVELS.find(l => l.id === levelId);
if (lv?.daily) {
const lessonIds = LEVELS.filter(l => l.tab === lv.tab && !l.daily).map(l => l.id);
const meta = newState();
const states = {};
return {
next: () => {
const id = drawBag(meta, 'lvl', lessonIds, meta.last.lvl);
meta.last.lvl = id;
if (!states[id]) states[id] = newState();
return gen(id, states[id]);
},
state: meta,
};
}
const state = newState();
return { next: () => gen(levelId, state), state };
}
// ═══════════════════════════════════════════
// FRAME REALIZATION
// ═══════════════════════════════════════════
function framesFor(prepKey, tw) {
const f = PREP_FRAMES[prepKey];
if (!f) return [];
if (Array.isArray(f)) return f;
return tw ? f.motion : f.location;
}
// Resolve a frame into concrete sentence parts. Returns null if the
// preposition has no usable frame for the given constraints.
// Which frames are usable for a given preposition and question flavour.
// Pure: no randomness, so the enumerator below sees exactly what players see.
export function usableFrames(prepKey, tw, { genderFilter, possOnly, indefOnly } = {}) {
let frames = framesFor(prepKey, tw);
// possOnly frames exist for possessive questions only — "bei meiner Schwester"
// is natural German, "bei der Schwester" is not.
frames = possOnly ? frames : frames.filter(f => !f.possOnly);
if (possOnly) {
frames = frames
.filter(f => f.poss !== false)
.map(f => ({...f, nouns: f.nouns.filter(n => POSSESSABLE.has(n))}))
.filter(f => f.nouns.length);
}
if (indefOnly) {
// ein-word questions. A frame whose English determiner is a baked-in
// definite idiom ("nach der Schule" → after school) can still work with an
// ein-word if it says which nouns to use instead: "nach einer Woche" is
// fine even though "während einer Woche" is not, so the allowance is
// per-frame rather than per-noun.
frames = frames
.filter(f => f.indefNouns || f.enDet === undefined)
.map(f => {
const g = {...f, nouns: f.indefNouns || f.nouns.filter(n => !NOUN_BY_NAME[n].noIndef)};
if (f.enPrepIndef) g.enPrep = f.enPrepIndef;
delete g.enDet; // the caller supplies a/an
return g;
})
.filter(f => f.nouns.length);
}
if (genderFilter) {
frames = frames
.map(f => ({...f, nouns: f.nouns.filter(n => genderFilter.includes(NOUN_BY_NAME[n].g))}))
.filter(f => f.nouns.length);
}
return frames;
}
// Turn one fully-chosen combination into sentence parts. Pure — every sentence
// the app can ever show comes through here, which is what makes the corpus
// snapshot in the tests exhaustive rather than a sample.
export function composeFrame(prepKey, cas, tw, frame, verbKey, nounName, subjKey) {
const noun = NOUN_BY_NAME[nounName];
let subjDe, subjEn, deVkey, enVkey;
if (frame.fixedSubj) {
subjDe = frame.fixedSubj.de;
subjEn = frame.fixedSubj.en;
deVkey = frame.fixedSubj.vkey || 'Er';
enVkey = frame.fixedSubj.enVkey || deVkey;
} else {
subjDe = subjKey; subjEn = SUBJ_EN[subjKey];
deVkey = subjKey; enVkey = subjKey;
}
const p = PREPS[prepKey];
let enPrep = frame.enPrep || p.en;
if (p.c === 'wechsel' && !frame.enPrep) enPrep = tw ? p.m : p.l;
const verbDe = VERBS[verbKey].de[deVkey];
const verbEn = (frame.enVerb || VERBS[verbKey].en)[enVkey];
const midDe = frame.mid ? ` ${frame.mid.de}` : '';
const midEn = frame.mid ? ` ${frame.mid.en}` : '';
const postDe = frame.post ? (frame.post[cas] || '') : '';
const postEn = frame.post ? frame.post.en : '';
const enDet = frame.enDet !== undefined ? frame.enDet : 'the';
// "bei + person" is "at someone's place" in English, never "at the sister".
const enPossS = !!frame.enPossS;
return { noun, verbKey, frame, subjDe, subjEn, verbDe, verbEn, midDe, midEn,
enPrep, postDe, postEn, enDet, enPossS };
}
function realizeFrame(prepKey, cas, tw, state, opts = {}) {
const frames = usableFrames(prepKey, tw, opts);
if (!frames.length) return null;
// Prefer frames that can avoid repeating the previous question's verb.
const fresh = frames.filter(f => f.verbs.some(v => v !== state.last.verb));
const frame = pick(fresh.length ? fresh : frames);
const nounName = pickAvoid(frame.nouns, state.last.noun);
const verbKey = pickAvoid(frame.verbs, state.last.verb);
state.last.noun = nounName;
state.last.verb = verbKey;
let subjKey = null;
if (!frame.fixedSubj) {
subjKey = pickAvoid(['Er','Sie','Wir','Ich'], state.last.subj);
state.last.subj = subjKey;
}
return composeFrame(prepKey, cas, tw, frame, verbKey, nounName, subjKey);
}
const enNoun = (r, det, poss = false) => `${det}${det ? ' ' : ''}${poss ? possNounEn(r.noun) : r.noun.en}${r.enPossS ? "'s" : ''}`;
// ═══════════════════════════════════════════
// QUESTION GENERATORS
// ═══════════════════════════════════════════
// The answer plus 3 distractors drawn from a pool, shuffled.
function pickChoices(ans, pool, n = 4) {
const rem = [...new Set(pool)].filter(a => a !== ans);
const picked = [];
while (picked.length < n - 1 && rem.length) picked.push(rem.splice(Math.floor(Math.random()*rem.length), 1)[0]);
return [ans, ...picked].sort(() => Math.random() - 0.5);
}
function articleChoices(ans, artType) {
return pickChoices(ans, artType === 'indefinite'
? ['ein','eine','einen','einem','eines','einer','keine','keinen','keiner']
: ['der','die','das','den','dem','des']);
}
// All declined forms of one possessive stem (mein, meine, meinen, meinem…) —
// every distractor shares the stem, so the choices never leak which ending is right.
function possChoices(stem, ans) {
const pool = [];
for (const g of Object.keys(POSS_ENDINGS)) {
for (const c of Object.keys(POSS_ENDINGS[g])) pool.push(combinePoss(stem, POSS_ENDINGS[g][c]));
}
return pickChoices(ans, pool);
}
// The target pronoun's own case forms first (mich vs mir vs ich is the real
// test), topped up with other pronouns' object forms.
function pronounChoices(pron, ans) {
const own = [...new Set([pron.nom, pron.acc, pron.dat])].filter(f => f !== ans);
const others = shuffle([...new Set(PRONOUNS.filter(p => p.key !== pron.key).flatMap(p => [p.acc, p.dat]))])
.filter(f => f !== ans && !own.includes(f));
const picked = [...shuffle(own), ...others].slice(0, 3);
return [ans, ...picked].sort(() => Math.random() - 0.5);
}
export function genArticleQ(level, state = newState(), depth = 0) {
const lv = LEVELS.find(l=>l.id===level);
const indef = lv.artType === 'indefinite';
// Draw preps until one yields a usable frame. With ein-words, mass nouns and
// definite idioms can rule a preposition out entirely (e.g. trotz, whose
// nouns are all uncountable — „trotz eines Wetters" is not German).
let prepKey, cas, tw = null, r = null;
for (let i = 0; i <= lv.preps.length && !r; i++) {
prepKey = drawBag(state, 'prep', lv.preps, state.last.prep);
const pc = PREPS[prepKey].c;
if (pc === 'wechsel') { tw = Math.random() > 0.5; cas = tw ? 'accusative' : 'dative'; }
else { tw = null; cas = pc; }
// Mixed definite lessons: contraction-capable prep → fused "chunk" question.
if (lv.mixed && lv.artType === 'definite' && CHUNK_PREPS.has(prepKey)) {
state.last.prep = prepKey;
return makeChunkQ(prepKey, cas, tw, state);
}
// Bare definite article: only genders that don't force a contraction.
r = realizeFrame(prepKey, cas, tw, state, indef
? { indefOnly: true }
: { genderFilter: nonFusingGenders(prepKey, cas) });
}
// No usable pairing for this preposition — draw again. Bounded so a data
// mistake degrades to one odd question instead of hanging the app.
if (!r) {
if (depth >= 8) r = realizeFrame(prepKey, cas, tw, state, {});
else return genArticleQ(level, state, depth + 1);
}
state.last.prep = prepKey;
const tbl = indef ? INDEF : DEF;
const ans = tbl[r.noun.g][cas];
// ein-words translate as a/an, never the frame's default "the".
const detEn = indef ? (/^[aeiou]/i.test(r.noun.en) ? 'an' : 'a') : r.enDet;
const sentence = `${r.subjDe} ${r.verbDe}${r.midDe} ${prepKey} _____ ${nounForm(r.noun, cas)}${r.postDe}.`;
const translation = `${r.subjEn} ${r.verbEn}${r.midEn} ${r.enPrep} ${enNoun(r, detEn)}${r.postEn}.`;
const rule = PREPS[prepKey].c === 'wechsel'
? (tw ? `„${prepKey}" + motion (wohin?) → Akkusativ` : `„${prepKey}" + location (wo?) → Dativ`)
: `„${prepKey}" → always ${CL[cas]}`;
return {type:'article', noun:r.noun, cas, prepKey, verb:r.verbKey, ans, sentence, translation, rule, tw, artType:lv.artType, choices:articleChoices(ans, lv.artType)};
}
export function genPrepQ(level, state = newState()) {
const lv = LEVELS.find(l=>l.id===level);
const prepKey = drawBag(state, 'prep', lv.preps, state.last.prep);
state.last.prep = prepKey;
const p = PREPS[prepKey];
if (p.ex) {
return {type:'prep', prepKey, prepEn:p.en, ans:p.c, example:p.ex.de, exampleEn:p.ex.en, exampleFull:true};
}
const exCase = p.c === 'wechsel' ? 'dative' : p.c;
const frames = framesFor(prepKey, false);
const nounName = frames.length ? pick(pick(frames).nouns) : pick(NOUNS).w;
const noun = NOUN_BY_NAME[nounName];
return {type:'prep', prepKey, prepEn:p.en, ans:p.c,
example:`${prepKey} ${DEF[noun.g][exCase]} ${nounForm(noun, exCase)}`, exampleEn:noun.en};
}
export function genWechselContextQ(level, state = newState()) {
const lv = LEVELS.find(l=>l.id===level);
const prepKey = drawBag(state, 'prep', lv.preps, state.last.prep);
state.last.prep = prepKey;
const isMotion = Math.random() > 0.5;
const cas = isMotion ? 'accusative' : 'dative';
const r = realizeFrame(prepKey, cas, isMotion, state);
const art = DEF[r.noun.g][cas];
const head = `${r.subjDe} ${r.verbDe}${r.midDe}`;
const tail = `${nounForm(r.noun, cas)}${r.postDe}.`;
// The article is BLANKED. With "unter dem Stuhl" on screen the answer is just
// article recognition; blanked, the only evidence is the verb (and whether it
// has a direct object) — which is the actual wohin/wo skill.
const sentence = `${head} ${prepKey} _____ ${tail}`;
// The solved sentence contracts where German really contracts (in dem → im).
const fused = CONTRACTABLE[prepKey]?.[art] || null;
const answerSentence = fused ? `${head} ${fused} ${tail}` : `${head} ${prepKey} ${art} ${tail}`;
const translation = `${r.subjEn} ${r.verbEn}${r.midEn} ${r.enPrep} ${enNoun(r, r.enDet)}${r.postEn}.`;
return {type:'wechselContext', prepKey, ans:cas, cas, isMotion, verb:r.verbKey, noun:r.noun,
article:art, contraction:fused, sentence, answerSentence, translation, nounEn:r.noun.en};
}
const POSS_ACC_TEMPLATES = [
{de:'sehe', en:'see', nouns:[...PERSONS,'Hund','Katze','Haus','Garten','Auto']},
{de:'kenne', en:'know', nouns:PERSONS},
{de:'liebe', en:'love', nouns:[...PERSONS,'Hund','Katze','Stadt','Auto']},
{de:'höre', en:'hear', nouns:['Musik','Hund','Katze','Kind']},
];
export function genPossQ(level, state = newState()) {
const lv = LEVELS.find(l=>l.id===level);
const stem = pickAvoid(lv.possessives, state.last.stem);
state.last.stem = stem;
const possMeaning = POSSESSIVES.find(p=>p.stem===stem).en;
const wantPrep = Math.random() > 0.5;
if (wantPrep) {
const pool = lv.cases.includes('genitive')
? [...SENTENCE_ACC_PREPS,...PREPS_BY_CASE.dative,...PREPS_BY_CASE.wechsel,...PREPS_BY_CASE.genitive]
: [...SENTENCE_ACC_PREPS,...PREPS_BY_CASE.dative,...PREPS_BY_CASE.wechsel];
// Draw until we find a prep with a possessive-friendly frame.
for (let i = 0; i < pool.length; i++) {
const prepKey = drawBag(state, 'prep', pool, state.last.prep);
const pc = PREPS[prepKey].c;
let tw = null, cas;
if (pc === 'wechsel') { tw = Math.random() > 0.5; cas = tw ? 'accusative' : 'dative'; } else cas = pc;
if (!lv.cases.includes(cas)) continue;
const r = realizeFrame(prepKey, cas, tw, state, { possOnly:true });
if (!r) continue;
state.last.prep = prepKey;
const ans = combinePoss(stem, POSS_ENDINGS[r.noun.g][cas]);
const sentence = `${r.subjDe} ${r.verbDe}${r.midDe} ${prepKey} _____ ${nounForm(r.noun, cas)}${r.postDe}.`;
const translation = `${r.subjEn} ${r.verbEn}${r.midEn} ${r.enPrep} ${possMeaning} ${possNounEn(r.noun)}${r.postEn}.`;
const rule = `${stem}- + ${CL[cas]} + ${r.noun.g} → ${ans}`;
return {type:'poss', noun:r.noun, stem, possMeaning, cas, ans, sentence, translation, rule, prepKey, verb:r.verbKey, tw, choices:possChoices(stem, ans)};
}
}
// No preposition: simple object sentence.
const cas = pick(lv.cases.filter(c => c !== 'genitive')) || lv.cases[0];
let noun, sentence, translation, tplVerb;
if (cas === 'accusative') {
const t = pick(POSS_ACC_TEMPLATES);
tplVerb = t.de;
noun = NOUN_BY_NAME[pickAvoid(t.nouns.filter(n => POSSESSABLE.has(n)), state.last.noun)];
sentence = `Ich ${t.de} _____ ${noun.w}.`;
translation = `I ${t.en} ${possMeaning} ${possNounEn(noun)}.`;
} else {
tplVerb = 'helfen';
noun = NOUN_BY_NAME[pickAvoid(PERSONS.filter(n => POSSESSABLE.has(n)), state.last.noun)];
sentence = `Ich helfe _____ ${noun.w}.`;
translation = `I help ${possMeaning} ${possNounEn(noun)}.`;
}
state.last.noun = noun.w;
const ans = combinePoss(stem, POSS_ENDINGS[noun.g][cas]);
const rule = `${stem}- + ${CL[cas]} + ${noun.g} → ${ans}`;
return {type:'poss', noun, stem, possMeaning, cas, ans, sentence, translation, rule,
prepKey:null, verb:tplVerb, tw:null, choices:possChoices(stem, ans)};
}
export function genPronounQ(level, state = newState()) {
const lv = LEVELS.find(l=>l.id===level);
const pronKey = drawBag(state, 'pron', lv.pronouns, state.last.pron);
state.last.pron = pronKey;
const pron = PRONOUNS.find(p=>p.key===pronKey);
const cas = pick(lv.cases);
const ans = pron[{accusative:'acc',dative:'dat'}[cas]];
const subjCandidates = SAFE_SUBJECT_KEYS.filter(k => k !== pronKey);
const subjKey = pick(subjCandidates);
const subj = PRONOUNS.find(p=>p.key===subjKey);
let tpl;
if (cas === 'accusative') {
tpl = pick([{v:'sehen'}, {v:'kennen'}, {v:'lieben'}]);
} else {
tpl = pick([{v:'helfen'}, {v:'geben', dePost:' das Buch', enPost:' the book'}]);
}
const sDe = VC[tpl.v].de[subjKey];
const sEn = VC[tpl.v].en[subjKey];
const dePost = tpl.dePost || '';
const enPost = tpl.enPost || '';
const sentence = `${cap(subj.nom)} ${sDe} _____${dePost}.`;
const translation = `${cap(subj.en)} ${sEn} ${pron.enObj}${enPost}.`;
return {type:'pronoun', pronKey, pron, cas, ans, sentence, translation, choices:pronounChoices(pron, ans)};
}
// Every preposition that fuses, and every article that shows up inside a
// contraction plus two that never do — so "which article is in there?" can't be
// answered by elimination.
export const CONTRACTION_PREPS = [...new Set(CONTRACTIONS.map(c => c.prep))];
const CONTRACTION_ARTS = ['das','dem','der','die','den'];
// Asked the other way round: the fused form is GIVEN and you take it apart.
// Asking "hinter + das = ?" was no test at all — only one option could start
// with "hinter". Going backwards, the real question survives: -s is das, -m is
// dem, -r is der, and am/ans, im/ins, zum/zur are the pairs that actually cost
// you marks.
export function genContractionQ(state = newState()) {
const form = drawBag(state, 'contr', CONTRACTIONS.map(c=>c.form), state.last.contr);
state.last.contr = form;
const c = CONTRACTIONS.find(x => x.form === form);
return {type:'contraction', prep:c.prep, art:c.art, ans:c.form, pref:c.pref, cas:c.c,
answerKey: `${c.prep} ${c.art}`,
prepChoices: pickChoices(c.prep, CONTRACTION_PREPS, 6),
artChoices: pickChoices(c.art, CONTRACTION_ARTS, 4)};
}
// Build the 4 choices for a fused prep+article question: always a BLEND of
// contracted and two-word forms, so the shape never reveals whether it contracts.
export function buildChunkChoices(prepKey, article) {
const map = CONTRACTABLE[prepKey] || {};
const contracts = !!map[article];
const correct = contracts ? map[article] : `${prepKey} ${article}`;
const cand = new Set();
Object.values(map).forEach(f => cand.add(f)); // contraction shapes
Object.keys(map).forEach(a => cand.add(`${prepKey} ${a}`)); // their two-word forms
cand.add(`${prepKey} ${article}`); // two-word of the real article
cand.delete(correct);
const pool = [...cand].sort(() => Math.random() - 0.5);
const choices = [correct, ...pool.slice(0, 3)];
const filler = ['das','dem','der','die','den'].map(a => `${prepKey} ${a}`);
for (let i = 0; i < filler.length && choices.length < 4; i++) {
if (!choices.includes(filler[i])) choices.push(filler[i]);
}
return { correct, contracts, choices: choices.slice(0, 4).sort(() => Math.random() - 0.5) };
}
export function makeChunkQ(prepKey, cas, tw, state = newState(), genderFilter) {
const r = realizeFrame(prepKey, cas, tw, state, { genderFilter });
const article = DEF[r.noun.g][cas];
const { correct, contracts, choices } = buildChunkChoices(prepKey, article);
const sentence = `${r.subjDe} ${r.verbDe}${r.midDe} _____ ${nounForm(r.noun, cas)}${r.postDe}.`;
const translation = `${r.subjEn} ${r.verbEn}${r.midEn} ${r.enPrep} ${enNoun(r, r.enDet)}${r.postEn}.`;
const pref = contracts ? (CONTRACTIONS.find(x => x.prep === prepKey && x.art === article)?.pref ?? true) : null;
return { type:'chunk', prepKey, noun:r.noun, cas, verb:r.verbKey, ans:correct, choices, sentence, translation, tw, contracts, pref };
}
// Dedicated "Kontraktionen" level: answer ALWAYS contracts (preferred forms only).
export function genContractionContextQ(state = newState()) {
const prefForms = CONTRACTIONS.filter(x => x.pref && CHUNK_PREPS.has(x.prep));
const form = drawBag(state, 'contr', prefForms.map(c=>c.form), state.last.contr);
state.last.contr = form;
const c = prefForms.find(x => x.form === form);
const wechsel = WECHSEL_SET.has(c.prep);
let cas, genders, tw = null;
if (c.art === 'das') { cas='accusative'; genders=['neuter']; tw = wechsel ? true : null; }
else if (c.art === 'dem') { cas='dative'; genders=['masculine','neuter']; tw = wechsel ? false : null; }
else { cas='dative'; genders=['feminine']; tw = wechsel ? false : null; }
return makeChunkQ(c.prep, cas, tw, state, genders);
}
// Build one adjective question. Order matters: the class picks the determiner,
// the determiner constrains the nouns ("ein Wasser" is not a thing), the noun
// picks the adjective.
function buildAdjective(lv, state) {
for (let attempt = 0; attempt < 16; attempt++) {
const cls = drawBag(state, 'adjcls', lv.classes, state.last.adjcls);
let frames = ADJ_FRAMES.filter(f =>
lv.cases.includes(f.cas) &&
(!f.classes || f.classes.includes(cls)) &&
(!lv.prepOnly || f.prep));
// Strong only occurs where German really drops the determiner: mass nouns.
if (cls === 'strong') frames = frames.filter(f => framePool(f).some(n => MASS_LIKE.has(n)));
if (!frames.length) continue;
const frame = pick(frames);
const detObj = cls === 'strong' ? null : pick(cls === 'weak' ? DER_WORD_DETS : EIN_WORD_DETS);
let pool = framePool(frame);
if (cls === 'strong') pool = pool.filter(n => MASS_LIKE.has(n));
// "ein Wasser" is not a thing, but "kein Geld" and "meine Zeit" are.
if (detObj?.stem === 'ein') pool = pool.filter(n => !NOUN_BY_NAME[n].noIndef);
if (!pool.length) continue;
let candidates = ADJECTIVES.filter(a => adjNouns(a).some(n => pool.includes(n)));
if (frame.noNeg) candidates = candidates.filter(a => !a.neg);
if (!candidates.length) continue;
const adj = pickAvoid(candidates, state.last.adj);
const nounName = pickAvoid(adjNouns(adj).filter(n => pool.includes(n)), state.last.noun);
state.last.adj = adj; state.last.noun = nounName; state.last.adjcls = cls;
const noun = NOUN_BY_NAME[nounName];
const cas = frame.cas;
const det = determinerFor(cls, detObj, noun.g, cas);
const ending = ADJ_ENDINGS[cls][noun.g][cas];
const nounDe = nounForm(noun, cas);
// Fuse the preposition with a definite article the way German really does.
const fused = frame.prep && det ? CONTRACTABLE[frame.prep]?.[det] : null;
const lead = !frame.prep ? (det ? det + ' ' : '')
: fused ? fused + ' '
: `${frame.prep} ${det ? det + ' ' : ''}`;
let blanked = `${lead}${adj.de}_____ ${nounDe}`;
let solved = `${lead}${adj.de}${ending} ${nounDe}`;
if (frame.deCap) { blanked = cap(blanked); solved = cap(solved); }
let enDet = cls === 'strong' ? '' : (detObj.definite ? 'the' : detObj.en);
const adjE = adjEn(adj, noun);
if (enDet === 'a' && /^[aeiou]/i.test(adjE)) enDet = 'an';
const nounEn = detObj?.poss ? possNounEn(noun) : noun.en;
let enPhrase = `${enDet}${enDet ? ' ' : ''}${adjE} ${nounEn}`;
if (frame.enCap) enPhrase = cap(enPhrase);
return {
cls, cas, frame, adj, noun, det, detObj, ending, fused,
sentence: `${frame.dePre}${blanked}${frame.dePost}`,
answerSentence: `${frame.dePre}${solved}${frame.dePost}`,
translation: `${frame.enPre}${enPhrase}${frame.enPost}`,
};
}
return null;
}
export function genAdjectiveQ(level, state = newState()) {
const lv = LEVELS.find(l => l.id === level);
const b = buildAdjective(lv, state);
return {
type:'adjective', cls:b.cls, cas:b.cas, frameId:b.frame.id, prepKey:b.frame.prep || null,
adj:b.adj, noun:b.noun, det:b.det, ans:b.ending,
sentence:b.sentence, answerSentence:b.answerSentence, translation:b.translation,
choices: pickChoices(b.ending, ADJ_ALL_ENDINGS, 4),
};
}
// The meta-skill: name the table before you can pick the ending.
export function genAdjClassQ(level, state = newState()) {
const lv = LEVELS.find(l => l.id === level);
const b = buildAdjective(lv, state);
return {
type:'adjClass', cls:b.cls, cas:b.cas, frameId:b.frame.id, prepKey:b.frame.prep || null,
adj:b.adj, noun:b.noun, det:b.det, ending:b.ending, ans:b.cls,
sentence:b.sentence, answerSentence:b.answerSentence, translation:b.translation,
choices:['weak','mixed','strong'],
};
}
export function gen(level, state = newState()) {
const lv = LEVELS.find(l=>l.id===level);
if (lv.type==='adjective') return genAdjectiveQ(level, state);
if (lv.type==='adjClass') return genAdjClassQ(level, state);
if (lv.type==='chunk') return genContractionContextQ(state);
if (lv.type==='contraction') return genContractionQ(state);
if (lv.type==='prep') return genPrepQ(level, state);
if (lv.type==='wechselContext') return genWechselContextQ(level, state);
if (lv.type==='poss') return genPossQ(level, state);
if (lv.type==='pronoun') return genPronounQ(level, state);
return genArticleQ(level, state);
}
// ═══════════════════════════════════════════
// CORPUS ENUMERATION
// Every sentence the engine can ever build, so the whole space can be read
// and approved instead of spot-checked. src/corpus.txt is the approved
// snapshot; engine.test.js fails if generation drifts from it.
// ═══════════════════════════════════════════
const CORPUS_SUBJ = 'Er';
function corpusLine(kind, prepKey, cas, tw, frame, verbKey, nounName, artFn) {
const r = composeFrame(prepKey, cas, tw, frame, verbKey, nounName, CORPUS_SUBJ);
const { art, enDet } = artFn(r, cas);
const de = `${r.subjDe} ${r.verbDe}${r.midDe} ${prepKey} ${art} ${nounForm(r.noun, cas)}${r.postDe}.`;
const en = `${r.subjEn} ${r.verbEn}${r.midEn} ${r.enPrep} ${enNoun(r, enDet, enDet === 'my')}${r.postEn}.`;
return `${kind.padEnd(6)} ${de}\n ${en}`;
}
// Every case a preposition can govern in a sentence level.
function casesFor(prepKey) {
const c = PREPS[prepKey].c;
return c === 'wechsel' ? [['accusative', true], ['dative', false]] : [[c, null]];
}
function corpusRec(kind, prepKey, cas, tw, frame, verbKey, nounName, artFn) {
const r = composeFrame(prepKey, cas, tw, frame, verbKey, nounName, CORPUS_SUBJ);
const { art, enDet, poss } = artFn(r, cas);
const de = `${r.subjDe} ${r.verbDe}${r.midDe} ${prepKey} ${art} ${nounForm(r.noun, cas)}${r.postDe}.`;
const en = `${r.subjEn} ${r.verbEn}${r.midEn} ${r.enPrep} ${enNoun(r, enDet, !!poss)}${r.postEn}.`;
return { key: `${kind}|${prepKey}|${cas}|${verbKey}|${nounName}`, de, en };
}
export function enumerateCorpus() {
const out = new Map();
const add = rec => { if (!out.has(rec.key)) out.set(rec.key, rec); };
const artPreps = [...new Set(LEVELS.filter(l => l.type === 'article' && l.preps).flatMap(l => l.preps))];
const wechPreps = PREPS_BY_CASE.wechsel;
for (const prepKey of [...new Set([...artPreps, ...wechPreps])]) {
for (const [cas, tw] of casesFor(prepKey)) {
if (artPreps.includes(prepKey)) {
for (const frame of usableFrames(prepKey, tw, { genderFilter: nonFusingGenders(prepKey, cas) })) {
for (const v of frame.verbs) for (const n of frame.nouns) {
add(corpusRec('ART', prepKey, cas, tw, frame, v, n,
r => ({ art: DEF[r.noun.g][cas], enDet: r.enDet })));
}
}
for (const frame of usableFrames(prepKey, tw, { indefOnly: true })) {
for (const v of frame.verbs) for (const n of frame.nouns) {
add(corpusRec('INDEF', prepKey, cas, tw, frame, v, n,
r => ({ art: INDEF[r.noun.g][cas],
enDet: /^[aeiou]/i.test(r.noun.en) ? 'an' : 'a' })));
}
}
}
if (wechPreps.includes(prepKey)) {
for (const frame of usableFrames(prepKey, tw, {})) {
for (const v of frame.verbs) for (const n of frame.nouns) {
const r = composeFrame(prepKey, cas, tw, frame, v, n, CORPUS_SUBJ);
const art = DEF[r.noun.g][cas];
const fused = CONTRACTABLE[prepKey]?.[art];
const head = `${r.subjDe} ${r.verbDe}${r.midDe}`;
const rest = `${nounForm(r.noun, cas)}${r.postDe}.`;
add({ key: `WECH|${prepKey}|${cas}|${v}|${n}`,
de: fused ? `${head} ${fused} ${rest}` : `${head} ${prepKey} ${art} ${rest}`,
en: `${r.subjEn} ${r.verbEn}${r.midEn} ${r.enPrep} ${enNoun(r, r.enDet)}${r.postEn}.` });
}
}
}
for (const frame of usableFrames(prepKey, tw, { possOnly: true })) {
for (const v of frame.verbs) for (const n of frame.nouns) {
add(corpusRec('POSS', prepKey, cas, tw, frame, v, n,
r => ({ art: combinePoss('mein', POSS_ENDINGS[r.noun.g][cas]), enDet: 'my', poss: true })));
}
}
if (CHUNK_PREPS.has(prepKey)) {
for (const frame of usableFrames(prepKey, tw, {})) {
for (const v of frame.verbs) for (const n of frame.nouns) {
const art = DEF[NOUN_BY_NAME[n].g][cas];
const fused = CONTRACTABLE[prepKey]?.[art];
const r = composeFrame(prepKey, cas, tw, frame, v, n, CORPUS_SUBJ);
add({ key: `CHUNK|${prepKey}|${cas}|${v}|${n}`,
de: `${r.subjDe} ${r.verbDe}${r.midDe} ${fused || `${prepKey} ${art}`} ${nounForm(r.noun, cas)}${r.postDe}.`,
en: `${r.subjEn} ${r.verbEn}${r.midEn} ${r.enPrep} ${enNoun(r, r.enDet)}${r.postEn}.` });
}
}
}
}
}
// Adjective sentences. Keyed on the SEMANTIC pairing — frame x adjective x
// noun — because that is what carries the risk ("kalter Kaffee" fine,
// "kalte Mutter" not). The determiner, class and ending are mechanical and
// covered exhaustively by the ending-table tests instead, which keeps this
// from exploding into hundreds of thousands of lines.
for (const frame of ADJ_FRAMES) {
const pool = framePool(frame);
for (const adj of ADJECTIVES) {
if (frame.noNeg && adj.neg) continue;
for (const n of adjNouns(adj)) {
if (!pool.includes(n)) continue;
const noun = NOUN_BY_NAME[n];
const cas = frame.cas;
const det = DEF[noun.g][cas];
const ending = ADJ_ENDINGS.weak[noun.g][cas];
const fused = frame.prep ? CONTRACTABLE[frame.prep]?.[det] : null;
const lead = !frame.prep ? det + ' ' : fused ? fused + ' ' : `${frame.prep} ${det} `;
let solved = `${lead}${adj.de}${ending} ${nounForm(noun, cas)}`;
if (frame.deCap) solved = cap(solved);
let enPhrase = `the ${adjEn(adj, noun)} ${noun.en}`;
if (frame.enCap) enPhrase = cap(enPhrase);
add({ key: `ADJ|${frame.id}|${adj.de}|${n}`,
de: `${frame.dePre}${solved}${frame.dePost}`,
en: `${frame.enPre}${enPhrase}${frame.enPost}` });
}
}
}
// Possessive sentences with no preposition at all.
for (const t of POSS_ACC_TEMPLATES) {
for (const n of t.nouns.filter(x => POSSESSABLE.has(x))) {
const noun = NOUN_BY_NAME[n];
add({ key: `POSSTPL|-|accusative|${t.de}|${n}`,
de: `Ich ${t.de} ${combinePoss('mein', POSS_ENDINGS[noun.g].accusative)} ${noun.w}.`,
en: `I ${t.en} my ${possNounEn(noun)}.` });
}
}
for (const n of PERSONS.filter(x => POSSESSABLE.has(x))) {
const noun = NOUN_BY_NAME[n];
add({ key: `POSSTPL|-|dative|helfen|${n}`,
de: `Ich helfe ${combinePoss('mein', POSS_ENDINGS[noun.g].dative)} ${noun.w}.`,
en: `I help my ${possNounEn(noun)}.` });
}
return [...out.values()].sort((a, b) => a.key.localeCompare(b.key));
}
// The corpus key for a question the app actually produced. One definition,
// used by the coverage test — so the snapshot can't drift into fiction.
export function corpusKeyFor(q) {
if (!q?.noun) return null;
const n = q.noun.w;
if (q.type === 'wechselContext') return `WECH|${q.prepKey}|${q.cas}|${q.verb}|${n}`;
if (q.type === 'chunk') return `CHUNK|${q.prepKey}|${q.cas}|${q.verb}|${n}`;
if (q.type === 'article') return `${q.artType === 'indefinite' ? 'INDEF' : 'ART'}|${q.prepKey}|${q.cas}|${q.verb}|${n}`;
if (q.type === 'adjective' || q.type === 'adjClass')
return `ADJ|${q.frameId}|${q.adj.de}|${n}`;
if (q.type === 'poss') return q.prepKey
? `POSS|${q.prepKey}|${q.cas}|${q.verb}|${n}`
: `POSSTPL|-|${q.cas}|${q.verb}|${n}`;
return null;
}
// The reviewed snapshot format: one greppable line per sentence.
export const corpusToText = recs =>
recs.map(r => `${r.key.padEnd(46)} ${r.de.padEnd(58)} ${r.en}`).join('\n') + '\n';
// ═══════════════════════════════════════════
// STREAKS
// Local dates (not UTC) so a late-evening session counts as the
// day the user actually experienced.
// ═══════════════════════════════════════════
export function localDateStr(d = new Date()) {
const p = n => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}`;
}
const dayDiff = (a, b) => Math.round((Date.parse(b) - Date.parse(a)) / 864e5);
// Only the trailing window needed for the 7-day dots is kept.
function pruneDays(days, today) {
const keep = {};
for (const d of Object.keys(days)) {
const diff = dayDiff(d, today);
if (diff >= 0 && diff < 14) keep[d] = true;
}
return keep;
}
// Called on every answered question. Idempotent within a day.
export function bumpStreak(st, today = localDateStr()) {
if (st.ld === today) {
if (st.days?.[today]) return st;
return { ...st, days: pruneDays({ ...(st.days || {}), [today]: true }, today) };
}
const streak = st.ld && dayDiff(st.ld, today) === 1 ? (st.streak || 0) + 1 : 1;
const days = pruneDays({ ...(st.days || {}), [today]: true }, today);
return { ...st, streak, ld: today, days };
}
// The last 7 days (oldest first, today last), flagged with whether the
// user practiced on each — drives the little dots next to the streak.
export function weekDots(st, today = localDateStr()) {
return Array.from({ length: 7 }, (_, i) => {
const key = new Date(Date.parse(today) - (6 - i) * 864e5).toISOString().slice(0, 10);
return { date: key, practiced: !!st?.days?.[key], isToday: i === 6 };
});
}
// What to display: a streak is only alive if the last practice was
// today or yesterday — otherwise it shows 0 (it has lapsed).
export function currentStreak(st, today = localDateStr()) {
if (!st?.ld) return 0;
const d = dayDiff(st.ld, today);
return d >= 0 && d <= 1 ? (st.streak || 0) : 0;
}