Add the Verbs tab, and stop Daily rounds repeating a preposition
The possessive and pronoun levels already build sentences with no
preposition in them - "Ich kenne ___ Mann", "Ich helfe ___ Mann" - where
the case is decided purely by the verb. Six verbs were doing that silently
across seven levels (sehen, kennen, lieben, hoeren as accusative, helfen as
dative, geben as both) and nothing in the app had ever taught or tested it.
The framing the whole section rests on: there is no accusative list to
learn. Accusative is the default for hundreds of transitive verbs, so you
learn the dative exceptions - the same shape as the preposition lists the
app already teaches.
39 verbs of data, in four groups:
- accusative foils, including the ones English makes feel dative
(fragen, anrufen, treffen, unterstuetzen, moegen)
- 12 dative verbs: helfen, danken, antworten, glauben, folgen, gratulieren,
vertrauen, verzeihen, begegnen, zuhoeren, raten, schaden
- 4 that flip subject and object against English: gefallen, schmecken,
fehlen, gehoeren
- 8 two-object verbs, all covered by one rule: person Dativ, thing Akkusativ
Six lessons plus Daily Practice: Welcher Fall? / Die Fallen / Gefallen & Co.
/ Dativ + Akkusativ / Im Satz / Alles gemischt. The tab sits second, right
after Prepositions, so case government is learned before the forms that
depend on it.
Near-synonyms that disagree are modelled as a `trap` link, and a test
asserts every trap points at a real verb taking the other case and that the
link is symmetric - so fragen/antworten, treffen/begegnen, anrufen/zuhoeren,
unterstuetzen/helfen and moegen/gefallen cannot drift apart.
The inverted verbs carry an explicit English reordering rule rather than a
literal gloss, so "Das Fenster gefaellt der Aerztin" renders as "The doctor
likes the window" and never "The window pleases the doctor". Tested.
Verb sentences are enumerated into corpus.txt like everything else, keyed on
verb x noun, which is what caught "I lend the teacher the bed" and "I
recommend the girl the cupboard" before they shipped - two-object verbs now
name the things that can actually be handed over, and the English uses the
"to the person" order.
167 tests pass.
Known gap: a handful of verbs take the genitive (gedenken, beduerfen,
beschuldigen). They are formal and rare, so the rule panel mentions them and
the app does not drill them.
Daily rounds could serve the same preposition twice, even back-to-back
Reported as "für" twice in a row in the Prepositions daily. Measured before
the fix: 14% of prep-daily rounds had a back-to-back repeat and 51% repeated
some preposition.
A Daily round mixes several lessons, and each one was handed its own
newState(). The anti-repeat memory therefore only spanned one lesson's slice
of the round, so level 8 drawing "für" and level 10 drawing "für" could not
see each other. drawBag also only avoided the previous item when the bag
refilled, not on every draw.
- newState(shared) lets several lesson states run off one memory, and the
Daily hands the same one to every lesson it mixes.
- drawBag now skips anything already used this cycle, and never repeats the
previous item, checked on every draw rather than only at a refill.
- Kontraktionen drew through a different bag ('contr') and so bypassed the
preposition memory entirely, letting "im" follow "in". It now consults and
records the shared memory.
- Adjective frames were chosen with a plain pick(), so a Daily could repeat
a whole carrier sentence. They are dealt from the shared memory too.
Rejected draws are put BACK into the bag rather than discarded. The first
version burned them, which skewed the Kontraktionen deal 1.5x toward the
forms whose preposition is not shared with a sibling form; reinsertion holds
the spread at 1.04x.
After: every Daily and every preposition-drilling level is at 0% for both
back-to-back and within-round repeats. Two cases remain non-zero and are
arithmetic rather than bugs - a round is 10 questions, so Wechsel im Kontext
(9 prepositions) must repeat one, and the Stark adjective level has exactly
one prepositional carrier frame. The adjective tab is held to frame variety
rather than preposition variety, since there the preposition is scenery and
the ending is the question.
Tests pin all of it: no Daily may repeat a preposition, no level may repeat
one back-to-back where it has a choice, and no Daily may repeat a carrier
sentence back-to-back.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Uza6mhTpAvSwMQuFc4Cz8
This commit is contained in:
+237
-2
@@ -4,6 +4,7 @@ import {
|
|||||||
PRONOUNS, POSSESSIVES, POSS_ENDINGS, combinePoss, LEVELS,
|
PRONOUNS, POSSESSIVES, POSS_ENDINGS, combinePoss, LEVELS,
|
||||||
wechselExample, createSession, bumpStreak, currentStreak, weekDots,
|
wechselExample, createSession, bumpStreak, currentStreak, weekDots,
|
||||||
nounForm, ADJ_ENDINGS, ADJ_CLASS_LABEL, ADJ_CLASS_HINT, DER_ENDINGS,
|
nounForm, ADJ_ENDINGS, ADJ_CLASS_LABEL, ADJ_CLASS_HINT, DER_ENDINGS,
|
||||||
|
CASE_VERBS, VERB_BY_NAME, VERB_CASE_LABEL, DATIVE_VERBS,
|
||||||
} from './engine.js';
|
} from './engine.js';
|
||||||
|
|
||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
@@ -56,6 +57,7 @@ const Access = {
|
|||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{id:'prep', label:'Prepositions'},
|
{id:'prep', label:'Prepositions'},
|
||||||
|
{id:'verb', label:'Verbs'},
|
||||||
{id:'article', label:'Articles'},
|
{id:'article', label:'Articles'},
|
||||||
{id:'poss', label:'Possessives'},
|
{id:'poss', label:'Possessives'},
|
||||||
{id:'pronoun', label:'Pronouns'},
|
{id:'pronoun', label:'Pronouns'},
|
||||||
@@ -345,7 +347,7 @@ export default function App() {
|
|||||||
|
|
||||||
const rc = res.filter(Boolean).length;
|
const rc = res.filter(Boolean).length;
|
||||||
const cur = LEVELS.find(l=>l.id===lv);
|
const cur = LEVELS.find(l=>l.id===lv);
|
||||||
const chartSection = scr === 'menu' ? tab : (q?.type === 'wechselContext' ? 'prep' : q?.type === 'chunk' ? 'article' : (q?.type === 'adjective' || q?.type === 'adjClass') ? 'adj' : (q?.type || tab));
|
const chartSection = scr === 'menu' ? tab : (q?.type === 'wechselContext' ? 'prep' : q?.type === 'chunk' ? 'article' : (q?.type === 'adjective' || q?.type === 'adjClass') ? 'adj' : (q?.type === 'verbCase' || q?.type === 'verbSentence') ? 'verb' : (q?.type || tab));
|
||||||
const nextInTab = nextLevelInTab(lv);
|
const nextInTab = nextLevelInTab(lv);
|
||||||
const quickActive = !!cur?.daily || !!st.auto;
|
const quickActive = !!cur?.daily || !!st.auto;
|
||||||
const quickTick = quickActive && rev && ok;
|
const quickTick = quickActive && rev && ok;
|
||||||
@@ -768,6 +770,76 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* PRACTICE: which case does this verb take? */}
|
||||||
|
{scr === 'practice' && q?.type === 'verbCase' && (
|
||||||
|
<div style={{textAlign:'center', paddingTop:30}}>
|
||||||
|
<div style={{fontSize:13,color:'#999',marginBottom:8}}>Which case does this verb take?</div>
|
||||||
|
<div style={{fontSize:44, fontWeight:700, color:'#1a1a1a', margin:'8px 0 4px'}}>{q.verb.de}</div>
|
||||||
|
<div style={{fontSize:15, color:'#999', marginBottom:10}}>{q.verb.en}</div>
|
||||||
|
<div style={S.secondaryContext}>
|
||||||
|
<button style={{...S.ruleBtn, marginLeft:0}} onClick={()=>setRule('verb')}>ⓘ Rule</button>
|
||||||
|
</div>
|
||||||
|
{!rev ? (
|
||||||
|
<div style={{display:'flex',gap:10,justifyContent:'center',flexWrap:'wrap',maxWidth:360,margin:'8px auto 0'}}>
|
||||||
|
{q.choices.map((val,i) => (
|
||||||
|
<button key={val} style={{...S.choiceBtn, borderColor: CASE_C[val === 'both' ? 'dative' : val]+'40', minWidth:val==='both'?280:130}}
|
||||||
|
onClick={()=>doSubmit(val)}>
|
||||||
|
<div style={{fontWeight:700, color: CASE_C[val === 'both' ? 'dative' : val]}}>{VERB_CASE_LABEL[val]}</div>
|
||||||
|
{!IS_TOUCH && <div style={{fontSize:11,color:'#999',marginTop:2}}><kbd style={S.kbdSmall}>{i+1}</kbd></div>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : quickTick ? <QuickTick/> : (
|
||||||
|
<VerbCaseFeedback q={q} inp={inp} ok={ok} onRule={()=>setRule('verb')}/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* PRACTICE: the verb decides the case, in a sentence */}
|
||||||
|
{scr === 'practice' && q?.type === 'verbSentence' && (
|
||||||
|
<div style={{textAlign:'center', paddingTop:10}}>
|
||||||
|
<div style={{...S.sentence, animation:shake?'shake 0.3s':'none'}}>
|
||||||
|
{q.sentence.split('_____').map((part,i,a)=>(
|
||||||
|
<span key={i}>
|
||||||
|
{part}
|
||||||
|
{i < a.length-1 && (
|
||||||
|
<span style={{...S.blank,
|
||||||
|
borderBottomColor: rev?(ok?GREEN:RED):'#bbb',
|
||||||
|
color: rev?(ok?GREEN:RED):'#1a1a1a',
|
||||||
|
}}>{rev ? q.ans : ' '}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Hideable hidden={hintHidden('translation') && !rev} onToggle={()=>toggleHint('translation')} label="translation" style={S.translation}>
|
||||||
|
{q.translation}
|
||||||
|
</Hideable>
|
||||||
|
|
||||||
|
<Hideable hidden={hintHidden('gender')} onToggle={()=>toggleHint('gender')} label="gender" style={S.keyInfo}>
|
||||||
|
<span style={{color: GENDER_C[q.noun.g], fontWeight:700}}>{DEF[q.noun.g].nominative}</span>
|
||||||
|
{' '}{q.noun.w}
|
||||||
|
<span style={S.keyInfoMute}> ({q.noun.en})</span>
|
||||||
|
</Hideable>
|
||||||
|
|
||||||
|
<Hideable hidden={hintHidden('caseInfo')} onToggle={()=>toggleHint('caseInfo')} label="case" style={S.secondaryContext}>
|
||||||
|
<span style={{color: CASE_C[q.cas], fontWeight:600}}>{CL[q.cas]}</span>
|
||||||
|
{' · '}{q.verb.de} ({q.verb.en})
|
||||||
|
</Hideable>
|
||||||
|
|
||||||
|
<div style={S.secondaryContext}>
|
||||||
|
The verb decides
|
||||||
|
<button style={S.ruleBtn} onClick={()=>setRule('verb')}>ⓘ Rule</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!rev ? (
|
||||||
|
<AnswerGrid choices={q.choices} onPick={doSubmit}/>
|
||||||
|
) : quickTick ? <QuickTick/> : (
|
||||||
|
<VerbSentenceFeedback q={q} inp={inp} ok={ok} onRule={()=>setRule('verb')}/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* PRACTICE: adjective endings */}
|
{/* PRACTICE: adjective endings */}
|
||||||
{scr === 'practice' && (q?.type === 'adjective' || q?.type === 'adjClass') && (
|
{scr === 'practice' && (q?.type === 'adjective' || q?.type === 'adjClass') && (
|
||||||
<div style={{textAlign:'center', paddingTop:10}}>
|
<div style={{textAlign:'center', paddingTop:10}}>
|
||||||
@@ -904,7 +976,9 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{chart && <ChartModal section={chartSection} q={q} onClose={()=>setChart(false)} onRule={kind => { setChart(false); setRule(kind || true); }}/>}
|
{chart && <ChartModal section={chartSection} q={q} onClose={()=>setChart(false)} onRule={kind => { setChart(false); setRule(kind || true); }}/>}
|
||||||
{rule && (rule === 'adj' ? <AdjRuleModal onClose={()=>setRule(false)}/> : <WechselRuleModal onClose={()=>setRule(false)}/>)}
|
{rule && (rule === 'adj' ? <AdjRuleModal onClose={()=>setRule(false)}/>
|
||||||
|
: rule === 'verb' ? <VerbRuleModal onClose={()=>setRule(false)}/>
|
||||||
|
: <WechselRuleModal onClose={()=>setRule(false)}/>)}
|
||||||
{nounInfo && <NounEndingModal q={nounInfo} onClose={()=>setNounInfo(null)}/>}
|
{nounInfo && <NounEndingModal q={nounInfo} onClose={()=>setNounInfo(null)}/>}
|
||||||
|
|
||||||
<style>{`
|
<style>{`
|
||||||
@@ -1362,6 +1436,153 @@ function NounEndingModal({ q, onClose }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function VerbCaseFeedback({ q, inp, ok, onRule }) {
|
||||||
|
return (
|
||||||
|
<div style={{...S.feedback, animation:'fadeUp 0.2s ease-out', maxWidth:420}}>
|
||||||
|
<div style={{fontSize:15, fontWeight:600, marginBottom:8, color:ok?GREEN:'#1a1a1a'}}>
|
||||||
|
{ok ? <>✓ <span style={{color:CASE_C[q.ans==='both'?'dative':q.ans]}}>{VERB_CASE_LABEL[q.ans]}</span></>
|
||||||
|
: <>✗ <span style={{textDecoration:'line-through',color:'#999',fontWeight:400}}>{VERB_CASE_LABEL[inp]||inp}</span> <span style={{color:'#999',fontWeight:400}}>→</span> <span style={{color:CASE_C[q.ans==='both'?'dative':q.ans]}}>{VERB_CASE_LABEL[q.ans]}</span></>}
|
||||||
|
</div>
|
||||||
|
<div style={{fontSize:13, color:'#666', marginBottom:8, lineHeight:1.55}}>
|
||||||
|
{q.ans === 'dative'
|
||||||
|
? <>„{q.verb.de}" is one of the dative verbs — there is no reason for it, it is a list you learn.</>
|
||||||
|
: q.ans === 'both'
|
||||||
|
? <>„{q.verb.de}" takes two objects: the <strong>person is Dativ</strong>, the <strong>thing is Akkusativ</strong>.</>
|
||||||
|
: <>„{q.verb.de}" is an ordinary transitive verb, and <strong>Akkusativ is the default</strong> — nothing to memorise.</>}
|
||||||
|
</div>
|
||||||
|
{q.verb.inverted && (
|
||||||
|
<div style={{fontSize:12, color:'#888', marginBottom:8, lineHeight:1.55}}>
|
||||||
|
It also flips: the German subject is the English object.
|
||||||
|
<em> Das Buch gefällt mir</em> = "I like the book".
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{q.trap && (
|
||||||
|
<div style={{fontSize:12, lineHeight:1.6, background:'#fafafa', borderRadius:6, padding:'8px 10px'}}>
|
||||||
|
Watch out — <strong>{q.trap.de}</strong> means almost the same thing but takes the{' '}
|
||||||
|
<span style={{color:CASE_C[q.trap.cas==='both'?'dative':q.trap.cas], fontWeight:600}}>{VERB_CASE_LABEL[q.trap.cas]}</span>.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button style={{...S.ruleBtn, marginLeft:0, marginTop:10}} onClick={onRule}>ⓘ The dative list</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VerbSentenceFeedback({ q, inp, ok, onRule }) {
|
||||||
|
return (
|
||||||
|
<div style={{...S.feedback, animation:'fadeUp 0.2s ease-out', maxWidth:420}}>
|
||||||
|
<div style={{fontSize:15, fontWeight:600, marginBottom:8, color:ok?GREEN:'#1a1a1a'}}>
|
||||||
|
{ok ? <>✓ <span style={{color:GREEN}}>{q.ans}</span></>
|
||||||
|
: <>✗ <span style={{textDecoration:'line-through',color:'#999',fontWeight:400}}>{inp}</span> <span style={{color:'#999',fontWeight:400}}>→</span> <span style={{color:GREEN}}>{q.ans}</span></>}
|
||||||
|
</div>
|
||||||
|
<div style={{fontSize:15, fontWeight:600, color:'#1a1a1a', marginBottom:10, lineHeight:1.45}}>
|
||||||
|
{q.answerSentence}
|
||||||
|
</div>
|
||||||
|
<div style={{fontSize:13, color:'#666', marginBottom:8, lineHeight:1.55}}>
|
||||||
|
{q.verb.cas === 'both'
|
||||||
|
? <>„{q.verb.de}" takes two objects — the <strong>person is Dativ</strong> ({q.slot === 'dative' ? 'this blank' : 'already filled'}),
|
||||||
|
the <strong>thing is Akkusativ</strong> ({q.slot === 'accusative' ? 'this blank' : 'already filled'}).</>
|
||||||
|
: <>„{q.verb.de}" always takes the <span style={{color:CASE_C[q.cas], fontWeight:600}}>{CL[q.cas]}</span>
|
||||||
|
{q.verb.cas === 'dative' ? ' — one of the verbs you have to learn.' : ', the ordinary default.'}</>}
|
||||||
|
</div>
|
||||||
|
{q.verb.inverted && (
|
||||||
|
<div style={{fontSize:12, color:'#888', marginBottom:8, lineHeight:1.6}}>
|
||||||
|
Note the flip: <strong>{q.subjNoun.w}</strong> is the German subject, but the English
|
||||||
|
subject is <strong>{q.noun.en}</strong>.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{fontSize:12, color:'#999', lineHeight:1.55}}>
|
||||||
|
<span style={{color:GENDER_C[q.noun.g]}}>{DEF[q.noun.g].nominative} {q.noun.w}</span>
|
||||||
|
{' '}+ <span style={{color:CASE_C[q.cas]}}>{CL[q.cas]}</span> → <strong style={{color:'#1a1a1a'}}>{q.ans}</strong>
|
||||||
|
</div>
|
||||||
|
<button style={{...S.ruleBtn, marginLeft:0, marginTop:10}} onClick={onRule}>ⓘ The dative list</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which case a verb governs: the default, the list, the traps, the flippers.
|
||||||
|
function VerbRuleModal({ onClose }) {
|
||||||
|
const dat = CASE_VERBS.filter(v => v.cas === 'dative' && !v.inverted);
|
||||||
|
const inv = CASE_VERBS.filter(v => v.inverted);
|
||||||
|
const two = CASE_VERBS.filter(v => v.cas === 'both');
|
||||||
|
const traps = CASE_VERBS.filter(v => v.cas === 'accusative' && v.trap)
|
||||||
|
.map(v => [v, VERB_BY_NAME[v.trap]]).filter(([, t]) => t && t.cas !== 'accusative');
|
||||||
|
return (
|
||||||
|
<div style={S.overlay} onClick={onClose}>
|
||||||
|
<div style={S.modal} onClick={e=>e.stopPropagation()}>
|
||||||
|
<div style={{display:'flex',justifyContent:'space-between',alignItems:'center',marginBottom:14}}>
|
||||||
|
<div style={{fontSize:17,fontWeight:700}}>Which case does the verb take?</div>
|
||||||
|
<button onClick={onClose} style={{background:'none',border:'none',fontSize:18,color:'#999',cursor:'pointer'}}>✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={S.ruleLead}>
|
||||||
|
There is no list of accusative verbs to learn. <strong>Akkusativ is the default</strong> —
|
||||||
|
hundreds of verbs take it, so you only have to learn the exceptions. The dative
|
||||||
|
list below is short, and almost everything else is accusative.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={S.ruleH}>The dative verbs</div>
|
||||||
|
<div style={S.ruleBody}>
|
||||||
|
These take a dative object where English gives you no warning at all.
|
||||||
|
</div>
|
||||||
|
<div style={{...S.ruleEx, lineHeight:1.9}}>
|
||||||
|
{dat.map((v, i) => (
|
||||||
|
<span key={v.de}>
|
||||||
|
<strong>{v.de}</strong> <span style={{color:'#999'}}>{v.en}</span>{i < dat.length - 1 ? ' · ' : ''}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={S.ruleH}>The traps</div>
|
||||||
|
<div style={S.ruleBody}>
|
||||||
|
Near-synonyms that disagree. Same idea, different case — these are where marks go.
|
||||||
|
</div>
|
||||||
|
<table style={S.fullChart}>
|
||||||
|
<thead><tr>
|
||||||
|
<th style={{...S.fcTh, textAlign:'left', color:CASE_C.accusative}}>Akkusativ</th>
|
||||||
|
<th style={{...S.fcTh, textAlign:'left', color:CASE_C.dative}}>Dativ</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{traps.map(([a, d]) => (
|
||||||
|
<tr key={a.de}>
|
||||||
|
<td style={{...S.fcTd, textAlign:'left'}}><strong>{a.de}</strong> <span style={{color:'#aaa',fontSize:11}}>{a.en}</span></td>
|
||||||
|
<td style={{...S.fcTd, textAlign:'left'}}><strong>{d.de}</strong> <span style={{color:'#aaa',fontSize:11}}>{d.en}</span></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div style={S.ruleH}>The ones that flip</div>
|
||||||
|
<div style={S.ruleBody}>
|
||||||
|
With these the German subject is the English object, which is why they feel
|
||||||
|
backwards: <em>Das Buch gefällt mir</em> is "<strong>I</strong> like the book",
|
||||||
|
not "the book likes me".
|
||||||
|
</div>
|
||||||
|
<div style={{...S.ruleEx, lineHeight:1.9}}>
|
||||||
|
{inv.map(v => <span key={v.de}><strong>{v.de}</strong> <span style={{color:'#999'}}>{v.en}</span> · </span>)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={S.ruleH}>Two objects at once</div>
|
||||||
|
<div style={S.ruleBody}>
|
||||||
|
One rule covers all of them: <strong>the person is Dativ, the thing is Akkusativ.</strong>
|
||||||
|
</div>
|
||||||
|
<div style={S.ruleEx}>Ich gebe <strong>dem</strong> Mann <strong>das</strong> Buch.</div>
|
||||||
|
<div style={{...S.ruleEx, lineHeight:1.9, marginTop:0}}>
|
||||||
|
{two.map(v => <span key={v.de}><strong>{v.de}</strong> <span style={{color:'#999'}}>{v.en}</span> · </span>)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{...S.ruleBody, marginTop:14, color:'#999', fontSize:12}}>
|
||||||
|
A handful of verbs take the genitive (<em>gedenken, bedürfen, beschuldigen</em>), but
|
||||||
|
they are formal and rare — not drilled here.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button style={{...S.nextBtn, width:'100%', marginTop:16}} onClick={onClose}>
|
||||||
|
Got it{!IS_TOUCH && <span style={{opacity:0.5,fontSize:12,marginLeft:4}}>esc</span>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// The three adjective tables and the single idea underneath them.
|
// The three adjective tables and the single idea underneath them.
|
||||||
function AdjRuleModal({ onClose }) {
|
function AdjRuleModal({ onClose }) {
|
||||||
const rows = [
|
const rows = [
|
||||||
@@ -1728,6 +1949,20 @@ function ChartModal({ section, q, onClose, onRule }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{section === 'verb' && (
|
||||||
|
<div style={{marginBottom:18}}>
|
||||||
|
<div style={S.chartH}>Verb Case Government</div>
|
||||||
|
<div style={{fontSize:11, color:'#999', marginBottom:10, lineHeight:1.5}}>
|
||||||
|
Akkusativ is the default — there is no list for it. Learn the dative verbs.
|
||||||
|
</div>
|
||||||
|
<div style={{fontSize:13, lineHeight:2, color:'#444'}}>
|
||||||
|
<div><strong style={{color:CASE_C.dative}}>Dativ:</strong> {DATIVE_VERBS.join(', ')}</div>
|
||||||
|
<div><strong style={{color:CASE_C.dative}}>Dativ + Akkusativ:</strong> {CASE_VERBS.filter(v=>v.cas==='both').map(v=>v.de).join(', ')}</div>
|
||||||
|
</div>
|
||||||
|
{onRule && <button style={{...S.ruleBtn, marginLeft:0, marginTop:10}} onClick={()=>onRule('verb')}>ⓘ Which case does the verb take?</button>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{section === 'adj' && (
|
{section === 'adj' && (
|
||||||
<div style={{marginBottom:18}}>
|
<div style={{marginBottom:18}}>
|
||||||
<div style={S.chartH}>Adjective Endings</div>
|
<div style={S.chartH}>Adjective Endings</div>
|
||||||
|
|||||||
+1744
File diff suppressed because it is too large
Load Diff
+287
-14
@@ -394,6 +394,88 @@ export const ADJ_FRAMES = [
|
|||||||
nouns:['Wetter','Regen','Musik'], classes:['weak','strong']},
|
nouns:['Wetter','Regen','Musik'], classes:['weak','strong']},
|
||||||
];
|
];
|
||||||
ADJ_FRAMES.forEach((f, i) => { f.id = i; });
|
ADJ_FRAMES.forEach((f, i) => { f.id = i; });
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// VERB CASE GOVERNMENT
|
||||||
|
// Accusative is the default for hundreds of transitive verbs, so there is no
|
||||||
|
// accusative list to learn — you learn the dative exceptions. That is the whole
|
||||||
|
// shape of this section, and it mirrors how the app already teaches the
|
||||||
|
// preposition lists.
|
||||||
|
// cas 'accusative' | 'dative' | 'both' ('both' = person dative, thing accusative)
|
||||||
|
// inverted : German subject is the English object (Das Buch gefällt mir)
|
||||||
|
// trap : the near-synonym that takes the OTHER case
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
const OBJ_PEOPLE = ['people'];
|
||||||
|
const OBJ_ANIMATE = ['people','animals'];
|
||||||
|
|
||||||
|
export const CASE_VERBS = [
|
||||||
|
// ── Akkusativ: the default. Present as foils, not as a list to memorise.
|
||||||
|
{de:'sehen', en:'see', ich:'sehe', er:'sieht', cas:'accusative', obj:['people','animals','things','buildings']},
|
||||||
|
{de:'kennen', en:'know', ich:'kenne', er:'kennt', cas:'accusative', obj:['people','settlements']},
|
||||||
|
{de:'lieben', en:'love', ich:'liebe', er:'liebt', cas:'accusative', obj:['people','animals','settlements']},
|
||||||
|
{de:'hören', en:'hear', ich:'höre', er:'hört', cas:'accusative', obj:['animals'], objNouns:['Musik','Kind']},
|
||||||
|
{de:'brauchen', en:'need', ich:'brauche', er:'braucht', cas:'accusative', obj:['things'], objNouns:['Geld','Zeit']},
|
||||||
|
{de:'suchen', en:'look for', ich:'suche', er:'sucht', cas:'accusative', obj:['people','animals','things']},
|
||||||
|
{de:'finden', en:'find', ich:'finde', er:'findet', cas:'accusative', obj:['things','animals']},
|
||||||
|
{de:'besuchen', en:'visit', ich:'besuche', er:'besucht', cas:'accusative', obj:['people','buildings','settlements']},
|
||||||
|
{de:'verstehen', en:'understand', ich:'verstehe', er:'versteht', cas:'accusative', obj:['people']},
|
||||||
|
{de:'kaufen', en:'buy', ich:'kaufe', er:'kauft', cas:'accusative', obj:['things'], objNouns:['Brot','Wein','Kaffee']},
|
||||||
|
// the traps: English makes these feel dative, German says accusative
|
||||||
|
{de:'fragen', en:'ask', ich:'frage', er:'fragt', cas:'accusative', obj:OBJ_PEOPLE, trap:'antworten'},
|
||||||
|
{de:'anrufen', en:'call', ich:'rufe', er:'ruft', cas:'accusative', obj:OBJ_PEOPLE, sep:'an', trap:'zuhören'},
|
||||||
|
{de:'treffen', en:'meet', ich:'treffe', er:'trifft', cas:'accusative', obj:OBJ_PEOPLE, trap:'begegnen'},
|
||||||
|
{de:'unterstützen', en:'support', ich:'unterstütze', er:'unterstützt', cas:'accusative', obj:OBJ_PEOPLE, trap:'helfen'},
|
||||||
|
{de:'mögen', en:'like', ich:'mag', er:'mag', cas:'accusative', obj:['people','animals','things'], trap:'gefallen'},
|
||||||
|
|
||||||
|
// ── Dativ: the list that actually has to be learned.
|
||||||
|
{de:'helfen', en:'help', ich:'helfe', er:'hilft', cas:'dative', obj:OBJ_ANIMATE, trap:'unterstützen'},
|
||||||
|
{de:'danken', en:'thank', ich:'danke', er:'dankt', cas:'dative', obj:OBJ_PEOPLE},
|
||||||
|
{de:'antworten', en:'answer', ich:'antworte', er:'antwortet', cas:'dative', obj:OBJ_PEOPLE, trap:'fragen'},
|
||||||
|
{de:'glauben', en:'believe', ich:'glaube', er:'glaubt', cas:'dative', obj:OBJ_PEOPLE},
|
||||||
|
{de:'folgen', en:'follow', ich:'folge', er:'folgt', cas:'dative', obj:OBJ_ANIMATE},
|
||||||
|
{de:'gratulieren', en:'congratulate', ich:'gratuliere', er:'gratuliert', cas:'dative', obj:OBJ_PEOPLE},
|
||||||
|
{de:'vertrauen', en:'trust', ich:'vertraue', er:'vertraut', cas:'dative', obj:OBJ_ANIMATE},
|
||||||
|
{de:'verzeihen', en:'forgive', ich:'verzeihe', er:'verzeiht', cas:'dative', obj:OBJ_PEOPLE},
|
||||||
|
{de:'begegnen', en:'meet', ich:'begegne', er:'begegnet', cas:'dative', obj:OBJ_PEOPLE, trap:'treffen'},
|
||||||
|
{de:'zuhören', en:'listen to', ich:'höre', er:'hört', cas:'dative', obj:OBJ_PEOPLE, sep:'zu', trap:'anrufen'},
|
||||||
|
{de:'raten', en:'advise', ich:'rate', er:'rät', cas:'dative', obj:OBJ_PEOPLE},
|
||||||
|
{de:'schaden', en:'harm', ich:'schade', er:'schadet', cas:'dative', obj:OBJ_ANIMATE},
|
||||||
|
|
||||||
|
// ── Dativ, inverted: the German subject is the English object.
|
||||||
|
{de:'gefallen', en:'please', er:'gefällt', cas:'dative', inverted:true, trap:'mögen',
|
||||||
|
enInv:{mode:'flip', v:'likes'}, obj:OBJ_PEOPLE, subj:['things','buildings'], subjNouns:['Musik','Bild','Auto','Haus']},
|
||||||
|
{de:'schmecken', en:'taste good to', er:'schmeckt', cas:'dative', inverted:true,
|
||||||
|
enInv:{mode:'flip', v:'likes'}, obj:OBJ_PEOPLE, subjNouns:['Kaffee','Tee','Brot','Milch']},
|
||||||
|
{de:'fehlen', en:'be missing to', er:'fehlt', cas:'dative', inverted:true,
|
||||||
|
enInv:{mode:'flip', v:'is missing'}, obj:OBJ_PEOPLE, subjNouns:['Geld','Buch','Auto','Tasche']},
|
||||||
|
{de:'gehören', en:'belong to', er:'gehört', cas:'dative', inverted:true,
|
||||||
|
enInv:{mode:'same', v:'belongs to'}, obj:OBJ_PEOPLE, subj:['things']},
|
||||||
|
|
||||||
|
// ── Dativ + Akkusativ: one rule covers all of them — person dative, thing accusative.
|
||||||
|
// Only things you can actually hand over, recommend or explain.
|
||||||
|
{de:'geben', en:'give', ich:'gebe', er:'gibt', cas:'both', obj:OBJ_PEOPLE, thingNouns:['Buch','Bild','Tasche','Geld','Brot']},
|
||||||
|
{de:'zeigen', en:'show', ich:'zeige', er:'zeigt', cas:'both', obj:OBJ_PEOPLE, thingNouns:['Buch','Bild','Auto','Haus','Garten']},
|
||||||
|
{de:'schenken', en:'give', ich:'schenke', er:'schenkt', cas:'both', obj:OBJ_PEOPLE, thingNouns:['Buch','Bild','Tasche','Auto']},
|
||||||
|
{de:'erklären', en:'explain', ich:'erkläre', er:'erklärt', cas:'both', obj:OBJ_PEOPLE, thingNouns:['Buch','Arbeit','Bild']},
|
||||||
|
{de:'bringen', en:'bring', ich:'bringe', er:'bringt', cas:'both', obj:OBJ_PEOPLE, thingNouns:['Buch','Tasche','Brot','Kaffee','Wein']},
|
||||||
|
{de:'schicken', en:'send', ich:'schicke', er:'schickt', cas:'both', obj:OBJ_PEOPLE, thingNouns:['Buch','Bild','Geld','Tasche']},
|
||||||
|
{de:'empfehlen', en:'recommend', ich:'empfehle', er:'empfiehlt', cas:'both', obj:OBJ_PEOPLE, thingNouns:['Buch','Restaurant','Hotel','Museum','Wein']},
|
||||||
|
{de:'leihen', en:'lend', ich:'leihe', er:'leiht', cas:'both', obj:OBJ_PEOPLE, thingNouns:['Buch','Auto','Tasche','Geld']},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const VERB_BY_NAME = Object.fromEntries(CASE_VERBS.map(v => [v.de, v]));
|
||||||
|
export const DATIVE_VERBS = CASE_VERBS.filter(v => v.cas === 'dative').map(v => v.de);
|
||||||
|
export const TRAP_VERBS = CASE_VERBS.filter(v => v.trap).map(v => v.de);
|
||||||
|
export const INVERTED_VERBS = CASE_VERBS.filter(v => v.inverted).map(v => v.de);
|
||||||
|
export const TWO_OBJECT_VERBS = CASE_VERBS.filter(v => v.cas === 'both').map(v => v.de);
|
||||||
|
export const VERB_CASE_LABEL = { accusative:'Akkusativ', dative:'Dativ', both:'Dativ + Akkusativ' };
|
||||||
|
|
||||||
|
const verbNouns = (v, key) => [...new Set([
|
||||||
|
...((v[key] || []).flatMap(g => ADJ_NOUN_GROUPS[g] || [])),
|
||||||
|
...(v[key + 'Nouns'] || []),
|
||||||
|
])];
|
||||||
|
|
||||||
const MASS_LIKE = new Set([...ADJ_NOUN_GROUPS.mass, ...ADJ_NOUN_GROUPS.weather]);
|
const MASS_LIKE = new Set([...ADJ_NOUN_GROUPS.mass, ...ADJ_NOUN_GROUPS.weather]);
|
||||||
const framePool = f => [...new Set([
|
const framePool = f => [...new Set([
|
||||||
...(f.groups || []).flatMap(g => ADJ_NOUN_GROUPS[g]),
|
...(f.groups || []).flatMap(g => ADJ_NOUN_GROUPS[g]),
|
||||||
@@ -515,6 +597,23 @@ export const LEVELS = [
|
|||||||
{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: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, 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},
|
{id:25.5, tab:'adj', name:'Daily Practice', desc:'Every adjective lesson, mixed · one-tap', daily:true},
|
||||||
|
|
||||||
|
// Verb case government. Accusative is the default, so these levels drill the
|
||||||
|
// dative exceptions, the near-synonyms that disagree, and the sentences where
|
||||||
|
// the verb is the ONLY thing deciding the case.
|
||||||
|
{id:30, tab:'verb', name:'Welcher Fall?', desc:'Which case does this verb take?',
|
||||||
|
verbs:[...CASE_VERBS.filter(v => v.cas !== 'both').map(v => v.de)], type:'verbCase'},
|
||||||
|
{id:31, tab:'verb', name:'Die Fallen', desc:'fragen vs antworten, treffen vs begegnen…',
|
||||||
|
verbs:TRAP_VERBS, type:'verbCase'},
|
||||||
|
{id:32, tab:'verb', name:'Gefallen & Co.', desc:'Verbs where German flips subject and object',
|
||||||
|
verbs:INVERTED_VERBS, type:'verbSentence'},
|
||||||
|
{id:33, tab:'verb', name:'Dativ + Akkusativ', desc:'Two objects: person Dativ, thing Akkusativ',
|
||||||
|
verbs:TWO_OBJECT_VERBS, type:'verbSentence'},
|
||||||
|
{id:34, tab:'verb', name:'Im Satz', desc:'No preposition — the verb decides the case',
|
||||||
|
verbs:CASE_VERBS.filter(v => v.cas !== 'both' && !v.inverted).map(v => v.de), type:'verbSentence'},
|
||||||
|
{id:35, tab:'verb', name:'Alles gemischt', desc:'Every verb, every pattern',
|
||||||
|
verbs:CASE_VERBS.map(v => v.de), type:'verbSentence'},
|
||||||
|
{id:35.5, tab:'verb', name:'Daily Practice', desc:'Every verb lesson, mixed · one-tap', daily:true},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Contractions (preposition + article). pref:true = preferred even in writing.
|
// Contractions (preposition + article). pref:true = preferred even in writing.
|
||||||
@@ -800,20 +899,40 @@ const shuffle = a => {
|
|||||||
// and never let a refill produce the same item twice in a row.
|
// and never let a refill produce the same item twice in a row.
|
||||||
function drawBag(state, key, pool, avoid) {
|
function drawBag(state, key, pool, avoid) {
|
||||||
let bag = state.bags[key];
|
let bag = state.bags[key];
|
||||||
if (!bag || !bag.length) {
|
if (!bag || !bag.length) { bag = shuffle(pool); state.bags[key] = bag; }
|
||||||
bag = shuffle(pool);
|
const seen = (state.seen[key] = state.seen[key] || new Set());
|
||||||
if (bag.length > 1 && bag[bag.length - 1] === avoid) {
|
|
||||||
bag.pop();
|
// Prefer something not yet used this cycle, and never repeat what was just
|
||||||
bag.unshift(avoid);
|
// shown. Checked on every draw, not only when the bag refills, so the rule
|
||||||
|
// still holds when two lessons draw from overlapping pools.
|
||||||
|
let idx = -1;
|
||||||
|
for (let i = bag.length - 1; i >= 0; i--) {
|
||||||
|
if (bag[i] !== avoid && !seen.has(bag[i])) { idx = i; break; }
|
||||||
}
|
}
|
||||||
state.bags[key] = bag;
|
if (idx === -1) {
|
||||||
|
// Everything has had a turn — start a fresh cycle, still avoiding a
|
||||||
|
// back-to-back repeat. Only a pool of one can force that.
|
||||||
|
seen.clear();
|
||||||
|
for (let i = bag.length - 1; i >= 0; i--) if (bag[i] !== avoid) { idx = i; break; }
|
||||||
|
if (idx === -1) idx = bag.length - 1;
|
||||||
}
|
}
|
||||||
return bag.pop();
|
const item = bag.splice(idx, 1)[0];
|
||||||
|
seen.add(item);
|
||||||
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function newState() {
|
// `shared` lets several lesson states run off ONE anti-repeat memory. A Daily
|
||||||
return { bags:{}, last:{ prep:null, verb:null, noun:null, subj:null } };
|
// round mixes lessons whose preposition pools overlap, so without this the
|
||||||
|
// memory only spans one lesson's slice of the round and the same word can come
|
||||||
|
// round again immediately — which is how "für" turned up twice in a row.
|
||||||
|
export function newState(shared) {
|
||||||
|
return {
|
||||||
|
bags: {},
|
||||||
|
last: shared?.last || { prep:null, verb:null, noun:null, subj:null },
|
||||||
|
seen: shared?.seen || {},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
export const sharedMemory = () => ({ last:{ prep:null, verb:null, noun:null, subj:null }, seen:{} });
|
||||||
|
|
||||||
// A practice session: one shuffle-bag + anti-repeat memory per round.
|
// A practice session: one shuffle-bag + anti-repeat memory per round.
|
||||||
// Daily levels mix questions from every lesson in their tab: a shuffle-bag
|
// Daily levels mix questions from every lesson in their tab: a shuffle-bag
|
||||||
@@ -823,13 +942,15 @@ export function createSession(levelId) {
|
|||||||
const lv = LEVELS.find(l => l.id === levelId);
|
const lv = LEVELS.find(l => l.id === levelId);
|
||||||
if (lv?.daily) {
|
if (lv?.daily) {
|
||||||
const lessonIds = LEVELS.filter(l => l.tab === lv.tab && !l.daily).map(l => l.id);
|
const lessonIds = LEVELS.filter(l => l.tab === lv.tab && !l.daily).map(l => l.id);
|
||||||
const meta = newState();
|
// One memory for the whole round, handed to every lesson it mixes.
|
||||||
|
const shared = sharedMemory();
|
||||||
|
const meta = newState(shared);
|
||||||
const states = {};
|
const states = {};
|
||||||
return {
|
return {
|
||||||
next: () => {
|
next: () => {
|
||||||
const id = drawBag(meta, 'lvl', lessonIds, meta.last.lvl);
|
const id = drawBag(meta, 'lvl', lessonIds, meta.last.lvl);
|
||||||
meta.last.lvl = id;
|
meta.last.lvl = id;
|
||||||
if (!states[id]) states[id] = newState();
|
if (!states[id]) states[id] = newState(shared);
|
||||||
return gen(id, states[id]);
|
return gen(id, states[id]);
|
||||||
},
|
},
|
||||||
state: meta,
|
state: meta,
|
||||||
@@ -1204,9 +1325,24 @@ export function makeChunkQ(prepKey, cas, tw, state = newState(), genderFilter) {
|
|||||||
// Dedicated "Kontraktionen" level: answer ALWAYS contracts (preferred forms only).
|
// Dedicated "Kontraktionen" level: answer ALWAYS contracts (preferred forms only).
|
||||||
export function genContractionContextQ(state = newState()) {
|
export function genContractionContextQ(state = newState()) {
|
||||||
const prefForms = CONTRACTIONS.filter(x => x.pref && CHUNK_PREPS.has(x.prep));
|
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);
|
// This level draws a contraction, not a preposition, so it used to bypass the
|
||||||
|
// shared preposition memory entirely — letting a Daily round show "im" right
|
||||||
|
// after "in". Redraw past anything the round has already used, then record it.
|
||||||
|
const seenPrep = (state.seen.prep = state.seen.prep || new Set());
|
||||||
|
let form, c;
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
form = drawBag(state, 'contr', prefForms.map(x => x.form), state.last.contr);
|
||||||
|
c = prefForms.find(x => x.form === form);
|
||||||
|
if (c.prep !== state.last.prep && !seenPrep.has(c.prep)) break;
|
||||||
|
// Put a rejected form back rather than burning it: this level teaches the
|
||||||
|
// contraction, so every form must still be dealt evenly. Reinserted below
|
||||||
|
// the draw end so it is not simply handed straight back.
|
||||||
|
const bag = state.bags.contr;
|
||||||
|
bag.splice(Math.floor(Math.random() * bag.length), 0, form);
|
||||||
|
}
|
||||||
state.last.contr = form;
|
state.last.contr = form;
|
||||||
const c = prefForms.find(x => x.form === form);
|
state.last.prep = c.prep;
|
||||||
|
seenPrep.add(c.prep);
|
||||||
const wechsel = WECHSEL_SET.has(c.prep);
|
const wechsel = WECHSEL_SET.has(c.prep);
|
||||||
let cas, genders, tw = null;
|
let cas, genders, tw = null;
|
||||||
if (c.art === 'das') { cas='accusative'; genders=['neuter']; tw = wechsel ? true : null; }
|
if (c.art === 'das') { cas='accusative'; genders=['neuter']; tw = wechsel ? true : null; }
|
||||||
@@ -1229,7 +1365,26 @@ function buildAdjective(lv, state) {
|
|||||||
if (cls === 'strong') frames = frames.filter(f => framePool(f).some(n => MASS_LIKE.has(n)));
|
if (cls === 'strong') frames = frames.filter(f => framePool(f).some(n => MASS_LIKE.has(n)));
|
||||||
if (!frames.length) continue;
|
if (!frames.length) continue;
|
||||||
|
|
||||||
const frame = pick(frames);
|
// Deal frames through the shared round memory too, so a Daily round does not
|
||||||
|
// serve the same carrier sentence (or the same preposition) twice.
|
||||||
|
// Each class has its own eligible frame list, so the bag is keyed per class;
|
||||||
|
// `last.adjframe` is shared, so back-to-back is still avoided across them.
|
||||||
|
const fkey = 'adjframe:' + cls;
|
||||||
|
let frame;
|
||||||
|
for (let t = 0; t < 4; t++) {
|
||||||
|
const fid = drawBag(state, fkey, frames.map(f => f.id), state.last.adjframe);
|
||||||
|
frame = frames.find(f => f.id === fid) || pick(frames);
|
||||||
|
// Several frames share a preposition, so also step past one the round
|
||||||
|
// just used — putting the rejected frame back so variety stays even.
|
||||||
|
if (!frame.prep || frame.prep !== state.last.prep) break;
|
||||||
|
const bag = state.bags[fkey];
|
||||||
|
bag.splice(Math.floor(Math.random() * bag.length), 0, fid);
|
||||||
|
}
|
||||||
|
state.last.adjframe = frame.id;
|
||||||
|
if (frame.prep) {
|
||||||
|
state.last.prep = frame.prep;
|
||||||
|
(state.seen.prep = state.seen.prep || new Set()).add(frame.prep);
|
||||||
|
}
|
||||||
const detObj = cls === 'strong' ? null : pick(cls === 'weak' ? DER_WORD_DETS : EIN_WORD_DETS);
|
const detObj = cls === 'strong' ? null : pick(cls === 'weak' ? DER_WORD_DETS : EIN_WORD_DETS);
|
||||||
|
|
||||||
let pool = framePool(frame);
|
let pool = framePool(frame);
|
||||||
@@ -1302,8 +1457,86 @@ export function genAdjClassQ(level, state = newState()) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// "Which case does this verb take?" — the same shape as the preposition quiz.
|
||||||
|
export function genVerbCaseQ(level, state = newState()) {
|
||||||
|
const lv = LEVELS.find(l => l.id === level);
|
||||||
|
const de = drawBag(state, 'cverb', lv.verbs, state.last.cverb);
|
||||||
|
state.last.cverb = de;
|
||||||
|
const v = VERB_BY_NAME[de];
|
||||||
|
const choices = lv.withBoth ? ['accusative','dative','both'] : ['accusative','dative'];
|
||||||
|
return { type:'verbCase', verb:v, ans:v.cas, choices,
|
||||||
|
trap: v.trap ? VERB_BY_NAME[v.trap] : null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// A sentence whose case is decided by the verb alone — no preposition in sight.
|
||||||
|
export function genVerbSentenceQ(level, state = newState()) {
|
||||||
|
const lv = LEVELS.find(l => l.id === level);
|
||||||
|
for (let attempt = 0; attempt < 12; attempt++) {
|
||||||
|
const de = drawBag(state, 'cverb', lv.verbs, state.last.cverb);
|
||||||
|
state.last.cverb = de;
|
||||||
|
const v = VERB_BY_NAME[de];
|
||||||
|
|
||||||
|
if (v.inverted) {
|
||||||
|
// "Das Buch gefällt _____ Mann." — the thing is the subject, the person
|
||||||
|
// is the dative object, which is the slot being asked about.
|
||||||
|
const subjName = pickAvoid(verbNouns(v, 'subj'), state.last.noun);
|
||||||
|
const objName = pickAvoid(verbNouns(v, 'obj'), subjName);
|
||||||
|
if (!subjName || !objName) continue;
|
||||||
|
state.last.noun = subjName;
|
||||||
|
const subj = NOUN_BY_NAME[subjName], obj = NOUN_BY_NAME[objName];
|
||||||
|
const ans = DEF[obj.g].dative;
|
||||||
|
const en = v.enInv.mode === 'flip'
|
||||||
|
? `The ${obj.en} ${v.enInv.v} the ${subj.en}.`
|
||||||
|
: `The ${subj.en} ${v.enInv.v} the ${obj.en}.`;
|
||||||
|
return { type:'verbSentence', verb:v, cas:'dative', noun:obj, subjNoun:subj, ans,
|
||||||
|
slot:'dative',
|
||||||
|
sentence: `${cap(DEF[subj.g].nominative)} ${subj.w} ${v.er} _____ ${obj.w}.`,
|
||||||
|
answerSentence: `${cap(DEF[subj.g].nominative)} ${subj.w} ${v.er} ${ans} ${obj.w}.`,
|
||||||
|
translation: en, choices: articleChoices(ans, 'definite') };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (v.cas === 'both') {
|
||||||
|
// Person dative, thing accusative. Blank whichever slot this question asks.
|
||||||
|
const objName = pickAvoid(verbNouns(v, 'obj'), state.last.noun);
|
||||||
|
const thingName = pickAvoid(verbNouns(v, 'thing'), objName);
|
||||||
|
if (!objName || !thingName) continue;
|
||||||
|
state.last.noun = objName;
|
||||||
|
const person = NOUN_BY_NAME[objName], thing = NOUN_BY_NAME[thingName];
|
||||||
|
const askDative = Math.random() > 0.5;
|
||||||
|
const datArt = DEF[person.g].dative, accArt = DEF[thing.g].accusative;
|
||||||
|
const ans = askDative ? datArt : accArt;
|
||||||
|
const full = `Ich ${v.ich} ${datArt} ${person.w} ${accArt} ${thing.w}.`;
|
||||||
|
return { type:'verbSentence', verb:v, cas: askDative ? 'dative' : 'accusative',
|
||||||
|
noun: askDative ? person : thing, otherNoun: askDative ? thing : person,
|
||||||
|
slot: askDative ? 'dative' : 'accusative', ans,
|
||||||
|
sentence: askDative
|
||||||
|
? `Ich ${v.ich} _____ ${person.w} ${accArt} ${thing.w}.`
|
||||||
|
: `Ich ${v.ich} ${datArt} ${person.w} _____ ${thing.w}.`,
|
||||||
|
answerSentence: full,
|
||||||
|
translation: `I ${v.en} the ${thing.en} to the ${person.en}.`,
|
||||||
|
choices: articleChoices(ans, 'definite') };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain single-object verb: the case is the verb's own.
|
||||||
|
const objName = pickAvoid(verbNouns(v, 'obj'), state.last.noun);
|
||||||
|
if (!objName) continue;
|
||||||
|
state.last.noun = objName;
|
||||||
|
const obj = NOUN_BY_NAME[objName];
|
||||||
|
const ans = DEF[obj.g][v.cas];
|
||||||
|
const tail = v.sep ? ` ${v.sep}` : '';
|
||||||
|
return { type:'verbSentence', verb:v, cas:v.cas, noun:obj, slot:v.cas, ans,
|
||||||
|
sentence: `Ich ${v.ich} _____ ${obj.w}${tail}.`,
|
||||||
|
answerSentence: `Ich ${v.ich} ${ans} ${obj.w}${tail}.`,
|
||||||
|
translation: `I ${v.en} the ${obj.en}.`,
|
||||||
|
choices: articleChoices(ans, 'definite') };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function gen(level, state = newState()) {
|
export function gen(level, state = newState()) {
|
||||||
const lv = LEVELS.find(l=>l.id===level);
|
const lv = LEVELS.find(l=>l.id===level);
|
||||||
|
if (lv.type==='verbCase') return genVerbCaseQ(level, state);
|
||||||
|
if (lv.type==='verbSentence') return genVerbSentenceQ(level, state);
|
||||||
if (lv.type==='adjective') return genAdjectiveQ(level, state);
|
if (lv.type==='adjective') return genAdjectiveQ(level, state);
|
||||||
if (lv.type==='adjClass') return genAdjClassQ(level, state);
|
if (lv.type==='adjClass') return genAdjClassQ(level, state);
|
||||||
if (lv.type==='chunk') return genContractionContextQ(state);
|
if (lv.type==='chunk') return genContractionContextQ(state);
|
||||||
@@ -1436,6 +1669,37 @@ export function enumerateCorpus() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verb sentences. Keyed on verb x noun(s) — again the semantic pairing, since
|
||||||
|
// the article form itself is mechanical and covered by the declension tests.
|
||||||
|
for (const v of CASE_VERBS) {
|
||||||
|
if (v.inverted) {
|
||||||
|
for (const sn of verbNouns(v, 'subj')) for (const on of verbNouns(v, 'obj')) {
|
||||||
|
if (sn === on) continue;
|
||||||
|
const subj = NOUN_BY_NAME[sn], obj = NOUN_BY_NAME[on];
|
||||||
|
add({ key: `VERB|${v.de}|${sn}|${on}`,
|
||||||
|
de: `${cap(DEF[subj.g].nominative)} ${subj.w} ${v.er} ${DEF[obj.g].dative} ${obj.w}.`,
|
||||||
|
en: v.enInv.mode === 'flip'
|
||||||
|
? `The ${obj.en} ${v.enInv.v} the ${subj.en}.`
|
||||||
|
: `The ${subj.en} ${v.enInv.v} the ${obj.en}.` });
|
||||||
|
}
|
||||||
|
} else if (v.cas === 'both') {
|
||||||
|
for (const pn of verbNouns(v, 'obj')) for (const tn of verbNouns(v, 'thing')) {
|
||||||
|
if (pn === tn) continue;
|
||||||
|
const person = NOUN_BY_NAME[pn], thing = NOUN_BY_NAME[tn];
|
||||||
|
add({ key: `VERB|${v.de}|${pn}|${tn}`,
|
||||||
|
de: `Ich ${v.ich} ${DEF[person.g].dative} ${person.w} ${DEF[thing.g].accusative} ${thing.w}.`,
|
||||||
|
en: `I ${v.en} the ${thing.en} to the ${person.en}.` });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const on of verbNouns(v, 'obj')) {
|
||||||
|
const obj = NOUN_BY_NAME[on];
|
||||||
|
add({ key: `VERB|${v.de}|${on}`,
|
||||||
|
de: `Ich ${v.ich} ${DEF[obj.g][v.cas]} ${obj.w}${v.sep ? ' ' + v.sep : ''}.`,
|
||||||
|
en: `I ${v.en} the ${obj.en}.` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Possessive sentences with no preposition at all.
|
// Possessive sentences with no preposition at all.
|
||||||
for (const t of POSS_ACC_TEMPLATES) {
|
for (const t of POSS_ACC_TEMPLATES) {
|
||||||
for (const n of t.nouns.filter(x => POSSESSABLE.has(x))) {
|
for (const n of t.nouns.filter(x => POSSESSABLE.has(x))) {
|
||||||
@@ -1463,6 +1727,15 @@ export function corpusKeyFor(q) {
|
|||||||
if (q.type === 'wechselContext') return `WECH|${q.prepKey}|${q.cas}|${q.verb}|${n}`;
|
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 === '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 === 'article') return `${q.artType === 'indefinite' ? 'INDEF' : 'ART'}|${q.prepKey}|${q.cas}|${q.verb}|${n}`;
|
||||||
|
if (q.type === 'verbSentence') {
|
||||||
|
if (q.verb.inverted) return `VERB|${q.verb.de}|${q.subjNoun.w}|${n}`;
|
||||||
|
if (q.verb.cas === 'both') {
|
||||||
|
const person = q.slot === 'dative' ? q.noun : q.otherNoun;
|
||||||
|
const thing = q.slot === 'dative' ? q.otherNoun : q.noun;
|
||||||
|
return `VERB|${q.verb.de}|${person.w}|${thing.w}`;
|
||||||
|
}
|
||||||
|
return `VERB|${q.verb.de}|${n}`;
|
||||||
|
}
|
||||||
if (q.type === 'adjective' || q.type === 'adjClass')
|
if (q.type === 'adjective' || q.type === 'adjClass')
|
||||||
return `ADJ|${q.frameId}|${q.adj.de}|${n}`;
|
return `ADJ|${q.frameId}|${q.adj.de}|${n}`;
|
||||||
if (q.type === 'poss') return q.prepKey
|
if (q.type === 'poss') return q.prepKey
|
||||||
|
|||||||
+150
-1
@@ -10,6 +10,7 @@ import {
|
|||||||
ADJ_ENDINGS, ADJ_STRONG, ADJ_WEAK, ADJ_MIXED, DER_ENDINGS, ADJECTIVES,
|
ADJ_ENDINGS, ADJ_STRONG, ADJ_WEAK, ADJ_MIXED, DER_ENDINGS, ADJECTIVES,
|
||||||
ADJ_FRAMES, ADJ_NOUN_GROUPS, adjNouns, determinerFor, DER_WORD_DETS,
|
ADJ_FRAMES, ADJ_NOUN_GROUPS, adjNouns, determinerFor, DER_WORD_DETS,
|
||||||
EIN_WORD_DETS, ADJ_ALL_ENDINGS,
|
EIN_WORD_DETS, ADJ_ALL_ENDINGS,
|
||||||
|
CASE_VERBS, VERB_BY_NAME, DATIVE_VERBS, TRAP_VERBS, INVERTED_VERBS, TWO_OBJECT_VERBS,
|
||||||
} from './engine.js';
|
} from './engine.js';
|
||||||
|
|
||||||
const lvl7Has = (qs, p) => qs.some(q => q.prepKey === p);
|
const lvl7Has = (qs, p) => qs.some(q => q.prepKey === p);
|
||||||
@@ -483,7 +484,7 @@ describe('daily practice', () => {
|
|||||||
const dailies = LEVELS.filter(l => l.daily);
|
const dailies = LEVELS.filter(l => l.daily);
|
||||||
|
|
||||||
it('every tab has exactly one daily level', () => {
|
it('every tab has exactly one daily level', () => {
|
||||||
expect(dailies.map(l => l.tab).sort()).toEqual(['adj', 'article', 'poss', 'prep', 'pronoun']);
|
expect(dailies.map(l => l.tab).sort()).toEqual(['adj', 'article', 'poss', 'prep', 'pronoun', 'verb']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(dailies.map(l => [l.id, l.tab]))('daily %s mixes every lesson type of the %s tab', (id, tab) => {
|
it.each(dailies.map(l => [l.id, l.tab]))('daily %s mixes every lesson type of the %s tab', (id, tab) => {
|
||||||
@@ -793,3 +794,151 @@ describe('adjective questions', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Verb case government ────────────────────────────────────
|
||||||
|
|
||||||
|
describe('verb case government', () => {
|
||||||
|
it('every verb declares a real case and the conjugations it needs', () => {
|
||||||
|
for (const v of CASE_VERBS) {
|
||||||
|
expect(['accusative','dative','both'], `${v.de}`).toContain(v.cas);
|
||||||
|
expect(v.en, `${v.de} has no English`).toBeTruthy();
|
||||||
|
// inverted verbs only ever appear in 3rd person, so they need `er` only
|
||||||
|
if (!v.inverted) expect(v.ich, `${v.de} has no ich-form`).toBeTruthy();
|
||||||
|
expect(v.er, `${v.de} has no er-form`).toBeTruthy();
|
||||||
|
}
|
||||||
|
expect(new Set(CASE_VERBS.map(v => v.de)).size).toBe(CASE_VERBS.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the dative list matches the reference', () => {
|
||||||
|
const REF = ['helfen','danken','antworten','glauben','folgen','gratulieren','vertrauen',
|
||||||
|
'verzeihen','begegnen','zuhören','raten','schaden',
|
||||||
|
'gefallen','schmecken','fehlen','gehören'];
|
||||||
|
expect([...DATIVE_VERBS].sort()).toEqual([...REF].sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('every trap points at a real verb that takes the OTHER case', () => {
|
||||||
|
for (const name of TRAP_VERBS) {
|
||||||
|
const v = VERB_BY_NAME[name];
|
||||||
|
const t = VERB_BY_NAME[v.trap];
|
||||||
|
expect(t, `${v.de} → unknown verb ${v.trap}`).toBeTruthy();
|
||||||
|
expect(t.cas, `${v.de} and ${t.de} both take ${v.cas}`).not.toBe(v.cas);
|
||||||
|
expect(t.trap, `${t.de} should point back at ${v.de}`).toBe(v.de);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inverted verbs all say how their English reorders', () => {
|
||||||
|
for (const name of INVERTED_VERBS) {
|
||||||
|
const v = VERB_BY_NAME[name];
|
||||||
|
expect(v.cas).toBe('dative');
|
||||||
|
expect(['flip','same'], `${v.de}`).toContain(v.enInv?.mode);
|
||||||
|
expect(v.enInv.v).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([30, 31, 32, 33, 34, 35])('level %s: the answer follows the verb', (id) => {
|
||||||
|
for (const q of runSession(id, 300)) {
|
||||||
|
if (q.type === 'verbCase') {
|
||||||
|
expect(q.ans).toBe(q.verb.cas);
|
||||||
|
expect(q.choices).toContain(q.ans);
|
||||||
|
} else {
|
||||||
|
expect(q.ans).toBe(DEF[q.noun.g][q.cas]);
|
||||||
|
expect(q.choices.length).toBe(4);
|
||||||
|
expect(q.choices).toContain(q.ans);
|
||||||
|
expect(q.sentence).toContain('_____');
|
||||||
|
expect(q.answerSentence).not.toContain('_____');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('two-object verbs are always person Dativ, thing Akkusativ', () => {
|
||||||
|
for (const q of runSession(33, 400)) {
|
||||||
|
expect(q.verb.cas).toBe('both');
|
||||||
|
const person = q.slot === 'dative' ? q.noun : q.otherNoun;
|
||||||
|
const thing = q.slot === 'dative' ? q.otherNoun : q.noun;
|
||||||
|
expect(q.answerSentence).toContain(`${DEF[person.g].dative} ${person.w}`);
|
||||||
|
expect(q.answerSentence).toContain(`${DEF[thing.g].accusative} ${thing.w}`);
|
||||||
|
// and both slots get asked about, not just one
|
||||||
|
}
|
||||||
|
const slots = new Set(runSession(33, 200).map(q => q.slot));
|
||||||
|
expect([...slots].sort()).toEqual(['accusative','dative']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inverted verbs put the German subject in the English object slot', () => {
|
||||||
|
for (const q of runSession(32, 300)) {
|
||||||
|
expect(q.cas).toBe('dative');
|
||||||
|
expect(q.subjNoun).toBeTruthy();
|
||||||
|
if (q.verb.enInv.mode === 'flip') {
|
||||||
|
// English leads with the dative person, not the German subject
|
||||||
|
expect(q.translation.startsWith(`The ${q.noun.en}`), q.translation).toBe(true);
|
||||||
|
} else {
|
||||||
|
expect(q.translation.startsWith(`The ${q.subjNoun.en}`), q.translation).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"Im Satz" never smuggles in a preposition — the verb is the only clue', () => {
|
||||||
|
const PREP_WORDS = Object.keys(PREPS);
|
||||||
|
for (const q of runSession(34, 300)) {
|
||||||
|
const words = q.answerSentence.toLowerCase().replace(/[.,]/g, '').split(' ');
|
||||||
|
const found = words.filter(w => PREP_WORDS.includes(w) && w !== q.verb.sep);
|
||||||
|
expect(found, `"${q.answerSentence}" contains ${found.join(',')}`).toEqual([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('each verb level drills only the verbs it lists', () => {
|
||||||
|
for (const id of [30, 31, 32, 33, 34, 35]) {
|
||||||
|
const lv = LEVELS.find(l => l.id === id);
|
||||||
|
for (const q of runSession(id, 200)) {
|
||||||
|
expect(lv.verbs, `level ${id}`).toContain(q.verb.de);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Anti-repeat across a round ──────────────────────────────
|
||||||
|
|
||||||
|
describe('no repeats within a round', () => {
|
||||||
|
const roundPreps = (id, n = 10) => {
|
||||||
|
const s = createSession(id);
|
||||||
|
return Array.from({ length: n }, () => s.next().prepKey).filter(Boolean);
|
||||||
|
};
|
||||||
|
|
||||||
|
it('a Daily round never shows the same preposition twice', () => {
|
||||||
|
// Dailies mix lessons whose pools overlap, so they must share one memory.
|
||||||
|
// Only the tabs that actually drill prepositions are held to this: in the
|
||||||
|
// adjective tab the preposition is scenery (the question is the ending) and
|
||||||
|
// only a handful of carrier frames use one, so demanding uniqueness there
|
||||||
|
// would starve the sentence variety that matters more.
|
||||||
|
const PREP_TABS = ['prep', 'article', 'poss'];
|
||||||
|
for (const lv of LEVELS.filter(l => l.daily && PREP_TABS.includes(l.tab))) {
|
||||||
|
for (let r = 0; r < 400; r++) {
|
||||||
|
const ps = roundPreps(lv.id);
|
||||||
|
expect(new Set(ps).size, `daily ${lv.id} repeated a preposition`).toBe(ps.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('a Daily round never repeats a carrier sentence back-to-back either', () => {
|
||||||
|
for (const lv of LEVELS.filter(l => l.daily)) {
|
||||||
|
for (let r = 0; r < 200; r++) {
|
||||||
|
const s = createSession(lv.id);
|
||||||
|
const f = Array.from({ length: 10 }, () => s.next().frameId);
|
||||||
|
for (let i = 1; i < f.length; i++) {
|
||||||
|
if (f[i] === undefined) continue;
|
||||||
|
expect(f[i], `daily ${lv.id} repeated frame ${f[i]}`).not.toBe(f[i - 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no level ever shows the same preposition back-to-back when it has a choice', () => {
|
||||||
|
for (const lv of LEVELS.filter(l => l.preps && l.preps.length > 1)) {
|
||||||
|
for (let r = 0; r < 200; r++) {
|
||||||
|
const ps = roundPreps(lv.id);
|
||||||
|
for (let i = 1; i < ps.length; i++) {
|
||||||
|
expect(ps[i], `level ${lv.id} repeated ${ps[i]} back-to-back`).not.toBe(ps[i - 1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user