Add testable question engine, streak fixes, mobile case grid
- New question-generation engine (shuffle-bag preposition selection, per-preposition sentence frames, genitive noun forms) in src/engine.js - Streak fixes: local-date handling, lapse display - Footer copy: "Get in touch" - 2x2 mobile case grid - Vitest suite (60 tests) covering the engine Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+793
@@ -0,0 +1,793 @@
|
||||
// ═══════════════════════════════════════════
|
||||
// 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'];
|
||||
|
||||
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)
|
||||
export const NOUNS = [
|
||||
{w:'Hund',g:'masculine',en:'dog',gen:'Hundes'},
|
||||
{w:'Mann',g:'masculine',en:'man',gen:'Mannes'},
|
||||
{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'},
|
||||
{w:'Lehrer',g:'masculine',en:'teacher',gen:'Lehrers'},
|
||||
{w:'Vater',g:'masculine',en:'father',gen:'Vaters'},
|
||||
{w:'Zug',g:'masculine',en:'train',gen:'Zuges'},
|
||||
{w:'Regen',g:'masculine',en:'rain',gen:'Regens'},
|
||||
{w:'Frau',g:'feminine',en:'woman'},
|
||||
{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'},
|
||||
{w:'Mutter',g:'feminine',en:'mother'},
|
||||
{w:'Tochter',g:'feminine',en:'daughter'},
|
||||
{w:'Musik',g:'feminine',en:'music'},
|
||||
{w:'Nacht',g:'feminine',en:'night'},
|
||||
{w:'Woche',g:'feminine',en:'week'},
|
||||
{w:'Arbeit',g:'feminine',en:'work'},
|
||||
{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'},
|
||||
{w:'Dach',g:'neuter',en:'roof',gen:'Daches'},
|
||||
{w:'Konzert',g:'neuter',en:'concert',gen:'Konzerts'},
|
||||
];
|
||||
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'];
|
||||
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',
|
||||
]);
|
||||
|
||||
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;
|
||||
|
||||
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'}},
|
||||
};
|
||||
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:1, tab:'article', name:'Nominativ', desc:'Gender basics: der, die, or das?', cases:['nominative'], preps:[], type:'article', artType:'definite'},
|
||||
{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, zu, aus, bei, nach, seit...', cases:['dative'], preps:PREPS_BY_CASE.dative, 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:['nominative','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:['nominative','accusative','dative','genitive'], preps:[...SENTENCE_ACC_PREPS,...PREPS_BY_CASE.dative,...PREPS_BY_CASE.genitive,...PREPS_BY_CASE.wechsel], type:'article', artType:'indefinite', mixed: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: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 — all cases', possessives:['mein','dein','sein'], cases:['nominative','accusative','dative'], type:'poss'},
|
||||
{id:12, tab:'poss', name:'Schwierige Possessive', desc:'ihr · unser · euer — all cases', possessives:['ihr','unser','euer'], cases:['nominative','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:['nominative','accusative','dative','genitive'], type:'poss'},
|
||||
|
||||
{id:14, tab:'pronoun', name:'Singular Pronomen', desc:'ich, du, er, sie, es — all cases', pronouns:SG_PRONOUN_KEYS, cases:['nominative','accusative','dative'], type:'pronoun'},
|
||||
{id:15, tab:'pronoun', name:'Plural Pronomen', desc:'wir, ihr, sie — all cases', pronouns:PL_PRONOUN_KEYS, cases:['nominative','accusative','dative'], type:'pronoun'},
|
||||
{id:16, tab:'pronoun', name:'Sie (formal)', desc:'Formal Sie — singular and plural', pronouns:FORMAL_PRONOUN_KEYS, cases:['nominative','accusative','dative'], type:'pronoun'},
|
||||
{id:17, tab:'pronoun', name:'Alle gemischt', desc:'All 10 pronouns, all cases', pronouns:ALL_PRONOUN_KEYS, cases:['nominative','accusative','dative'], type:'pronoun'},
|
||||
];
|
||||
|
||||
// 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)); // 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.
|
||||
// ═══════════════════════════════════════════
|
||||
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:PERSONS}],
|
||||
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}],
|
||||
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}],
|
||||
von: [{verbs:['kommen'], nouns:[...PERSONS,'Arbeit','Schule','Büro']}],
|
||||
zu: [{verbs:['gehen','fahren'], nouns:[...PERSONS,'Schule','Park','Museum','Büro','Restaurant','Hotel']}],
|
||||
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']}],
|
||||
|
||||
an: {
|
||||
motion: [{verbs:['gehen','laufen'], nouns:['Tür','Fenster']}],
|
||||
location:[{verbs:['stehen','warten','sitzen'], nouns:['Tür','Fenster']}],
|
||||
},
|
||||
auf: {
|
||||
motion: [
|
||||
{verbs:['legen'], mid:{de:'das Buch',en:'the book'}, nouns:['Tisch','Stuhl']},
|
||||
{verbs:['gehen'], nouns:['Straße']},
|
||||
{verbs:['springen'], fixedSubj:{de:'Die Katze',en:'The cat'}, nouns:['Tisch','Dach']},
|
||||
],
|
||||
location: [
|
||||
{verbs:['sitzen'], nouns:['Stuhl']},
|
||||
{verbs:['liegen'], fixedSubj:{de:'Das Buch',en:'The book'}, nouns:['Tisch']},
|
||||
{verbs:['stehen','warten'], nouns:['Straße']},
|
||||
],
|
||||
},
|
||||
hinter: {
|
||||
motion: [{verbs:['gehen','laufen'], nouns:['Haus','Baum','Garten','Schule']}],
|
||||
location:[{verbs:['stehen','warten'], nouns:['Haus','Baum','Tür','Schule']}],
|
||||
},
|
||||
in: {
|
||||
motion: [{verbs:['gehen','fahren','kommen','laufen'], nouns:PLACES_IN}],
|
||||
location: [
|
||||
{verbs:['wohnen'], nouns:['Stadt','Haus','Hotel']},
|
||||
{verbs:['sein','warten','sitzen','arbeiten'], nouns:PLACES_IN},
|
||||
],
|
||||
},
|
||||
neben: {
|
||||
motion: [
|
||||
{verbs:['stellen'], mid:{de:'die Lampe',en:'the lamp'}, nouns:['Tisch','Stuhl','Tür']},
|
||||
{verbs:['legen'], mid:{de:'das Buch',en:'the book'}, nouns:['Stuhl','Tisch']},
|
||||
],
|
||||
location: [
|
||||
{verbs:['stehen','sitzen'], nouns:['Tisch','Tür','Fenster']},
|
||||
{verbs:['wohnen'], nouns:['Schule','Park','Museum']},
|
||||
],
|
||||
},
|
||||
über: {
|
||||
motion: [
|
||||
{verbs:['gehen','laufen'], nouns:['Straße','Brücke']},
|
||||
{verbs:['hängen'], mid:{de:'die Lampe',en:'the lamp'}, nouns:['Tisch']},
|
||||
],
|
||||
location:[{verbs:['hängen'], fixedSubj:{de:'Die Lampe',en:'The lamp'}, nouns:['Tisch']}],
|
||||
},
|
||||
unter: {
|
||||
motion: [
|
||||
{verbs:['legen'], mid:{de:'das Buch',en:'the book'}, nouns:['Tisch','Stuhl']},
|
||||
{verbs:['laufen'], fixedSubj:{de:'Die Katze',en:'The cat'}, nouns:['Tisch','Baum']},
|
||||
],
|
||||
location: [
|
||||
{verbs:['sitzen','stehen','schlafen'], nouns:['Baum','Brücke']},
|
||||
{verbs:['schlafen'], fixedSubj:{de:'Die Katze',en:'The cat'}, nouns:['Tisch','Stuhl']},
|
||||
],
|
||||
},
|
||||
vor: {
|
||||
motion: [{verbs:['fahren','gehen'], nouns:['Haus','Tür','Schule','Hotel']}],
|
||||
location:[{verbs:['stehen','warten'], nouns:['Tür','Haus','Schule','Hotel','Museum','Restaurant']}],
|
||||
},
|
||||
zwischen: {
|
||||
motion: [{verbs:['stellen'], mid:{de:'die Lampe',en:'the lamp'}, nouns:['Tisch','Stuhl'],
|
||||
post:{accusative:' und das Fenster', dative:' und dem Fenster', en:' and the window'}}],
|
||||
location: [{verbs:['stehen','sitzen'], nouns:['Tisch','Stuhl'],
|
||||
post:{accusative:' und das Fenster', dative:' und dem Fenster', en:' and the window'}}],
|
||||
},
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// 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.
|
||||
export function createSession(levelId) {
|
||||
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.
|
||||
function realizeFrame(prepKey, cas, tw, state, { genderFilter, possOnly } = {}) {
|
||||
let frames = framesFor(prepKey, tw);
|
||||
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 (genderFilter) {
|
||||
frames = frames
|
||||
.map(f => ({...f, nouns: f.nouns.filter(n => genderFilter.includes(NOUN_BY_NAME[n].g))}))
|
||||
.filter(f => f.nouns.length);
|
||||
}
|
||||
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 noun = NOUN_BY_NAME[nounName];
|
||||
const verbKey = pickAvoid(frame.verbs, state.last.verb);
|
||||
state.last.noun = nounName;
|
||||
state.last.verb = verbKey;
|
||||
|
||||
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 {
|
||||
const subj = pickAvoid(['Er','Sie','Wir','Ich'], state.last.subj);
|
||||
state.last.subj = subj;
|
||||
subjDe = subj; subjEn = SUBJ_EN[subj];
|
||||
deVkey = subj; enVkey = subj;
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
return { noun, verbKey, subjDe, subjEn, verbDe, verbEn, midDe, midEn, enPrep, postDe, postEn, enDet };
|
||||
}
|
||||
|
||||
const enNoun = (r, det) => `${det}${det ? ' ' : ''}${r.noun.en}`;
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// QUESTION GENERATORS
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
const NOM_TEMPLATES = [
|
||||
{de:'ist hier', en:'is here'},
|
||||
{de:'ist da', en:'is there'},
|
||||
{de:'ist groß', en:'is big'},
|
||||
{de:'ist klein', en:'is small'},
|
||||
];
|
||||
|
||||
function articleChoices(ans, artType) {
|
||||
const pool = artType === 'indefinite'
|
||||
? ['ein','eine','einen','einem','eines','einer','keine','keinen','keiner']
|
||||
: ['der','die','das','den','dem','des'];
|
||||
const rem = pool.filter(a => a !== ans);
|
||||
const picked = [];
|
||||
while (picked.length < 3 && rem.length) picked.push(rem.splice(Math.floor(Math.random()*rem.length), 1)[0]);
|
||||
return [ans, ...picked].sort(() => Math.random() - 0.5);
|
||||
}
|
||||
|
||||
function nominativeArticleQ(lv, state) {
|
||||
const noun = NOUN_BY_NAME[drawBag(state, 'noun', NOUNS.map(n=>n.w), state.last.noun)];
|
||||
state.last.noun = noun.w;
|
||||
const tbl = lv.artType === 'indefinite' ? INDEF : DEF;
|
||||
const ans = tbl[noun.g].nominative;
|
||||
const t = pick(NOM_TEMPLATES);
|
||||
return {
|
||||
type:'article', noun, cas:'nominative', prepKey:null, ans,
|
||||
sentence:`_____ ${noun.w} ${t.de}.`,
|
||||
translation:`The ${noun.en} ${t.en}.`,
|
||||
rule:'Subject of the sentence → Nominativ',
|
||||
tw:null, artType:lv.artType, choices:articleChoices(ans, lv.artType),
|
||||
};
|
||||
}
|
||||
|
||||
export function genArticleQ(level, state = newState()) {
|
||||
const lv = LEVELS.find(l=>l.id===level);
|
||||
if (lv.id === 1) return nominativeArticleQ(lv, state);
|
||||
if (lv.mixed && Math.random() < 0.12) return nominativeArticleQ(lv, state);
|
||||
|
||||
const prepKey = drawBag(state, 'prep', lv.preps, state.last.prep);
|
||||
state.last.prep = prepKey;
|
||||
const pc = PREPS[prepKey].c;
|
||||
let tw = null, cas;
|
||||
if (pc === 'wechsel') { tw = Math.random() > 0.5; cas = tw ? 'accusative' : 'dative'; }
|
||||
else cas = pc;
|
||||
|
||||
// Mixed definite lessons: contraction-capable prep → fused "chunk" question.
|
||||
if (lv.mixed && lv.artType === 'definite' && CHUNK_PREPS.has(prepKey)) {
|
||||
return makeChunkQ(prepKey, cas, tw, state);
|
||||
}
|
||||
|
||||
const r = realizeFrame(prepKey, cas, tw, state);
|
||||
const tbl = lv.artType === 'indefinite' ? INDEF : DEF;
|
||||
const ans = tbl[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} ${enNoun(r, r.enDet)}${r.postEn}.`;
|
||||
const rule = pc === 'wechsel'
|
||||
? (tw ? `„${prepKey}" + motion (wohin?) → Akkusativ` : `„${prepKey}" + location (wo?) → Dativ`)
|
||||
: `„${prepKey}" → always ${CL[cas]}`;
|
||||
return {type:'article', noun:r.noun, cas, prepKey, 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 sentence = `${r.subjDe} ${r.verbDe}${r.midDe} ${prepKey} ${art} ${nounForm(r.noun, cas)}${r.postDe}.`;
|
||||
const translation = `${r.subjEn} ${r.verbEn}${r.midEn} ${r.enPrep} ${enNoun(r, r.enDet)}${r.postEn}.`;
|
||||
return {type:'wechselContext', prepKey, ans:cas, isMotion, verb:r.verbKey, sentence, 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} ${r.noun.en}${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, tw};
|
||||
}
|
||||
}
|
||||
|
||||
// No preposition: simple subject/object sentence.
|
||||
const cas = pick(lv.cases.filter(c => c !== 'genitive')) || lv.cases[0];
|
||||
let noun, sentence, translation;
|
||||
if (cas === 'nominative') {
|
||||
noun = NOUN_BY_NAME[pickAvoid([...POSSESSABLE], state.last.noun)];
|
||||
const t = pick(NOM_TEMPLATES);
|
||||
sentence = `_____ ${noun.w} ${t.de}.`;
|
||||
translation = `${cap(possMeaning)} ${noun.en} ${t.en}.`;
|
||||
} else if (cas === 'accusative') {
|
||||
const t = pick(POSS_ACC_TEMPLATES);
|
||||
noun = NOUN_BY_NAME[pickAvoid(t.nouns, state.last.noun)];
|
||||
sentence = `Ich ${t.de} _____ ${noun.w}.`;
|
||||
translation = `I ${t.en} ${possMeaning} ${noun.en}.`;
|
||||
} else {
|
||||
noun = NOUN_BY_NAME[pickAvoid(PERSONS, state.last.noun)];
|
||||
sentence = `Ich helfe _____ ${noun.w}.`;
|
||||
translation = `I help ${possMeaning} ${noun.en}.`;
|
||||
}
|
||||
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, tw:null};
|
||||
}
|
||||
|
||||
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 rawAns = pron[{nominative:'nom',accusative:'acc',dative:'dat'}[cas]];
|
||||
|
||||
let sentence, translation, ans;
|
||||
if (cas === 'nominative') {
|
||||
const tpl = pick([
|
||||
{v:'kommen', dePart:' aus Berlin', enPart:' from Berlin'},
|
||||
{v:'sein', dePart:' müde', enPart:' tired'},
|
||||
{v:'haben', dePart:' einen Hund', enPart:' a dog'},
|
||||
]);
|
||||
const vDe = VC[tpl.v].de[pronKey];
|
||||
const vEn = VC[tpl.v].en[pronKey];
|
||||
sentence = `_____ ${vDe}${tpl.dePart}.`;
|
||||
translation = `${cap(pron.en)} ${vEn}${tpl.enPart}.`;
|
||||
ans = cap(rawAns);
|
||||
} else {
|
||||
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 || '';
|
||||
sentence = `${cap(subj.nom)} ${sDe} _____${dePost}.`;
|
||||
translation = `${cap(subj.en)} ${sEn} ${pron.enObj}${enPost}.`;
|
||||
ans = rawAns;
|
||||
}
|
||||
return {type:'pronoun', pronKey, pron, cas, ans, sentence, translation};
|
||||
}
|
||||
|
||||
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};
|
||||
}
|
||||
|
||||
// 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, 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);
|
||||
}
|
||||
|
||||
export function gen(level, state = newState()) {
|
||||
const lv = LEVELS.find(l=>l.id===level);
|
||||
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);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// 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);
|
||||
|
||||
// Called on every answered question. Idempotent within a day.
|
||||
export function bumpStreak(st, today = localDateStr()) {
|
||||
if (st.ld === today) return st;
|
||||
const streak = st.ld && dayDiff(st.ld, today) === 1 ? (st.streak || 0) + 1 : 1;
|
||||
return { ...st, streak, ld: today };
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
Reference in New Issue
Block a user