21 Commits

Author SHA1 Message Date
Torsten Schulz (local)
039059b4ac feat(bisaya): enhance grammar focus and exercises in Bisaya course content
All checks were successful
Deploy to production / deploy (push) Successful in 4m10s
2026-09-22 09:54:27 +02:00
Torsten Schulz (local)
afbfdd8ccc feat(falukant): optimize retrieval of not baptized children by using character IDs
All checks were successful
Deploy to production / deploy (push) Successful in 3m16s
2026-09-17 06:42:27 +02:00
Torsten Schulz (local)
57a0e89156 feat(vocab): stabilize daily batch handling in SRS session and improve due item display
All checks were successful
Deploy to production / deploy (push) Successful in 3m8s
2026-09-16 10:13:24 +02:00
Torsten Schulz (local)
20505f7545 feat(i18n): add 'due' status message for vocabulary reviews in multiple languages
All checks were successful
Deploy to production / deploy (push) Successful in 2m52s
2026-09-11 14:26:33 +02:00
Torsten Schulz (local)
01ee72c700 feat(migration): add migration to remove invalid past marker from vocab_srs_item
All checks were successful
Deploy to production / deploy (push) Successful in 2m40s
2026-09-09 21:06:25 +02:00
Torsten Schulz (local)
b48d03ab70 feat(bisaya): add concrete choice question handling and update daily count logic for clarity
All checks were successful
Deploy to production / deploy (push) Successful in 3m8s
2026-09-09 15:41:17 +02:00
Torsten Schulz (local)
c462001ff0 feat(bisaya): update household and children age rules to 14, adjust related instructions and migration
All checks were successful
Deploy to production / deploy (push) Successful in 3m14s
2026-09-09 14:16:41 +02:00
Torsten Schulz (local)
3d235a621a feat(bisaya): implement child marriage proposal and wooing features, update age rules and relationships
All checks were successful
Deploy to production / deploy (push) Successful in 3m15s
2026-09-08 14:46:26 +02:00
Torsten Schulz (local)
0a9174c486 feat(bisaya): update instruction for soft refusal exercise for clarity
All checks were successful
Deploy to production / deploy (push) Successful in 3m19s
2026-09-07 15:52:53 +02:00
Torsten Schulz (local)
f317ed9ef0 feat(bisaya): update health gap exercise instruction for clarity
All checks were successful
Deploy to production / deploy (push) Successful in 3m26s
2026-09-07 15:31:57 +02:00
Torsten Schulz (local)
1149814f64 feat(bisaya): update gap fill exercise instruction to clarify first person usage
All checks were successful
Deploy to production / deploy (push) Successful in 3m40s
2026-09-07 15:13:55 +02:00
Torsten Schulz (local)
86f3b4c247 feat(bisaya): update kitchen dialog exercise and add migration for alignment
All checks were successful
Deploy to production / deploy (push) Successful in 3m27s
2026-09-04 16:07:49 +02:00
Torsten Schulz (local)
90686388c6 feat(bisaya): update prompts and add migration for food context gap exercise
All checks were successful
Deploy to production / deploy (push) Successful in 3m28s
2026-09-04 15:13:25 +02:00
Torsten Schulz (local)
7edb9d79e5 refactor(dashboard): simplify layout and remove unused sections in LoggedInView
All checks were successful
Deploy to production / deploy (push) Successful in 3m0s
2026-09-03 14:21:40 +02:00
Torsten Schulz (local)
d9a46dcb34 feat(news): implement news section with loading state and error handling in NoLoginView
All checks were successful
Deploy to production / deploy (push) Successful in 3m8s
2026-09-03 14:18:14 +02:00
Torsten Schulz (local)
c4977ec826 feat(migrations): add migration to replace tautological vocabulary prompts in Cebu travel lesson
All checks were successful
Deploy to production / deploy (push) Successful in 2m58s
2026-09-02 16:02:09 +02:00
Torsten Schulz (local)
b2e5d3d950 feat(course): add Cebu travel path with lessons and didactics for Bisaya courses
All checks were successful
Deploy to production / deploy (push) Successful in 24s
2026-09-02 14:03:39 +02:00
Torsten Schulz (local)
504f527a3f feat(exercises): enhance context in gap fill exercises and update migration for route descriptions
All checks were successful
Deploy to production / deploy (push) Successful in 3m21s
2026-09-02 13:52:26 +02:00
Torsten Schulz (local)
76da586b8e fix(deploy): provision Falukant release models
All checks were successful
Deploy to production / deploy (push) Successful in 3m3s
2026-09-02 07:44:28 +02:00
Torsten Schulz (local)
a3e2aaece8 chore(deploy): verify regular rollout [force-deploy]
Some checks failed
Deploy to production / deploy (push) Failing after 1m2s
2026-09-02 07:33:48 +02:00
Torsten Schulz (local)
691e840c7f feat(deploy): load Falukant models from release asset
All checks were successful
Deploy to production / deploy (push) Successful in 11s
2026-09-02 07:31:11 +02:00
51 changed files with 1449 additions and 379 deletions

View File

@@ -67,7 +67,7 @@ jobs:
fi fi
# App-Code-Änderungen, die einen echten Deploy benötigen # App-Code-Änderungen, die einen echten Deploy benötigen
if grep -E '^(frontend/|backend/)' changed-files.txt \ if grep -E '^(frontend/|backend/|update(-frontend|-backend)?\.sh$|update\.sh$|deploy-yourpart-bluegreen\.sh$|falukant-models\.env$)' changed-files.txt \
| grep -Ev "$COURSE_SCRIPT_PATTERN" >/dev/null; then | grep -Ev "$COURSE_SCRIPT_PATTERN" >/dev/null; then
echo "app_changed=true" >> "$GITHUB_OUTPUT" echo "app_changed=true" >> "$GITHUB_OUTPUT"
else else

View File

@@ -102,6 +102,10 @@ class FalukantController {
this.getPotentialHeirs = this._wrapWithUser((userId) => this.service.getPotentialHeirs(userId)); this.getPotentialHeirs = this._wrapWithUser((userId) => this.service.getPotentialHeirs(userId));
this.selectHeir = this._wrapWithUser((userId, req) => this.service.selectHeir(userId, req.body.heirId), { blockInDebtorsPrison: true }); this.selectHeir = this._wrapWithUser((userId, req) => this.service.selectHeir(userId, req.body.heirId), { blockInDebtorsPrison: true });
this.setHeir = this._wrapWithUser((userId, req) => this.service.setHeir(userId, req.body.childCharacterId), { blockInDebtorsPrison: true }); this.setHeir = this._wrapWithUser((userId, req) => this.service.setHeir(userId, req.body.childCharacterId), { blockInDebtorsPrison: true });
this.acceptChildMarriageProposal = this._wrapWithUser((userId, req) =>
this.service.acceptChildMarriageProposal(userId, req.params.childCharacterId, req.body.proposedCharacterId), { blockInDebtorsPrison: true });
this.advanceChildWooing = this._wrapWithUser((userId, req) =>
this.service.advanceChildWooing(userId, req.params.childCharacterId), { blockInDebtorsPrison: true });
this.acceptMarriageProposal = this._wrapWithUser((userId, req) => this.service.acceptMarriageProposal(userId, req.body.proposalId), { blockInDebtorsPrison: true }); this.acceptMarriageProposal = this._wrapWithUser((userId, req) => this.service.acceptMarriageProposal(userId, req.body.proposalId), { blockInDebtorsPrison: true });
this.cancelWooing = this._wrapWithUser(async (userId) => { this.cancelWooing = this._wrapWithUser(async (userId) => {
try { try {
@@ -162,8 +166,8 @@ class FalukantController {
this.getPartyTypes = this._wrapWithUser((userId) => this.service.getPartyTypes(userId)); this.getPartyTypes = this._wrapWithUser((userId) => this.service.getPartyTypes(userId));
this.createParty = this._wrapWithUser((userId, req) => { this.createParty = this._wrapWithUser((userId, req) => {
const { partyTypeId, musicId, banquetteId, nobilityIds, servantRatio } = req.body; const { partyTypeId, musicId, banquetteId, nobilityIds, servantRatio, relationshipId } = req.body;
return this.service.createParty(userId, partyTypeId, musicId, banquetteId, nobilityIds, servantRatio); return this.service.createParty(userId, partyTypeId, musicId, banquetteId, nobilityIds, servantRatio, relationshipId);
}, { successStatus: 201, blockInDebtorsPrison: true }); }, { successStatus: 201, blockInDebtorsPrison: true });
this.getParties = this._wrapWithUser((userId) => this.service.getParties(userId)); this.getParties = this._wrapWithUser((userId) => this.service.getParties(userId));

View File

@@ -7,11 +7,12 @@ import newsService from '../services/newsService.js';
export default { export default {
async getNews(req, res) { async getNews(req, res) {
const counter = Math.max(0, parseInt(req.query.counter, 10) || 0); const counter = Math.max(0, parseInt(req.query.counter, 10) || 0);
const count = Math.min(6, Math.max(1, parseInt(req.query.count, 10) || 1));
const language = (req.query.language || 'de').slice(0, 10); const language = (req.query.language || 'de').slice(0, 10);
const category = (req.query.category || 'top').slice(0, 50); const category = (req.query.category || 'top').slice(0, 50);
try { try {
const { results, nextPage } = await newsService.getNews({ counter, language, category }); const { results, nextPage } = await newsService.getNews({ counter, count, language, category });
res.json({ results, nextPage }); res.json({ results, nextPage });
} catch (error) { } catch (error) {
console.error('News getNews:', error); console.error('News getNews:', error);

View File

@@ -0,0 +1,29 @@
'use strict';
/**
* The generated exercise for "Wege & Verkehr" used a bare `{gap}` while its
* instruction asked for a route description. A gap must be embedded in the
* route so the learner can infer the expected direction from the context.
*/
module.exports = {
async up(queryInterface) {
await queryInterface.sequelize.query(`
UPDATE community.vocab_grammar_exercise AS exercise
SET title = 'Wege & Verkehr: Satz im Kontext ergänzen',
question_data = '{"type":"gap_fill","text":"{gap}. Tuo. Wala. Duol ra.","gaps":1}'::jsonb,
answer_data = '{"type":"gap_fill","answers":["Diretso"]}'::jsonb,
explanation = 'Die vollständige Wegbeschreibung lautet: "Diretso. Tuo. Wala. Duol ra."'
FROM community.vocab_course_lesson AS lesson
WHERE exercise.lesson_id = lesson.id
AND (lesson.id = 1532 OR (lesson.course_id = 1 AND lesson.lesson_number = 55))
AND lesson.title = 'Wege & Verkehr'
AND exercise.question_data->>'type' = 'gap_fill'
AND exercise.question_data->>'text' = '{gap}'
AND exercise.answer_data->'answers' @> '["tuo"]'::jsonb;
`);
},
async down() {
// Do not restore the context-free task.
}
};

View File

@@ -0,0 +1,48 @@
'use strict';
/** Replaces tautological vocabulary prompts in the Cebu travel lesson. */
module.exports = {
async up(queryInterface) {
await queryInterface.sequelize.transaction(async (transaction) => {
const baseWhere = `
exercise.lesson_id = lesson.id
AND (lesson.id = 1532 OR (lesson.course_id = 1 AND lesson.lesson_number = 55))
AND lesson.title = 'Wege & Verkehr'
`;
await queryInterface.sequelize.query(`
UPDATE community.vocab_grammar_exercise AS exercise
SET title = 'Wege & Verkehr: Fahrzeug finden',
instruction = 'Wähle das passende Wort für die Situation.',
question_data = '{"type":"multiple_choice","question":"Du suchst ein Fahrzeug für die Fahrt. Welches Wort passt?","options":["hunong","sakyanan","jeepney","dalan"]}'::jsonb,
answer_data = '{"type":"multiple_choice","correctAnswer":1}'::jsonb,
explanation = 'Sakyanan bedeutet Fahrzeug oder Auto.'
FROM community.vocab_course_lesson AS lesson
WHERE ${baseWhere} AND exercise.exercise_number = 1;
`, { transaction });
await queryInterface.sequelize.query(`
UPDATE community.vocab_grammar_exercise AS exercise
SET title = 'Wege & Verkehr: Mit dem Jeepney fahren',
instruction = 'Wähle das passende Wort für die Reisesituation.',
question_data = '{"type":"multiple_choice","question":"Du möchtest auf Cebu mit dem typischen Sammeltaxi fahren. Welches Wort passt?","options":["hunong","sakyanan","jeepney","dalan"]}'::jsonb,
answer_data = '{"type":"multiple_choice","correctAnswer":2}'::jsonb,
explanation = 'Ein Jeepney ist ein typisches öffentliches Sammeltaxi auf den Philippinen.'
FROM community.vocab_course_lesson AS lesson
WHERE ${baseWhere} AND exercise.exercise_number = 2;
`, { transaction });
await queryInterface.sequelize.query(`
UPDATE community.vocab_grammar_exercise AS exercise
SET title = 'Wege & Verkehr: Weg zum Hafen ergänzen',
instruction = 'Jemand erklärt dir den Weg zum Hafen. Welche Richtungsangabe fehlt?',
question_data = '{"type":"gap_fill","text":"{gap}. Tuo. Wala. Duol ra.","gaps":1}'::jsonb,
answer_data = '{"type":"gap_fill","answers":["Diretso"]}'::jsonb,
explanation = 'Diretso = geradeaus, tuo = rechts, wala = links und duol ra = es ist nur nah.'
FROM community.vocab_course_lesson AS lesson
WHERE ${baseWhere} AND exercise.exercise_number = 3;
`, { transaction });
});
},
async down() {
// The former questions were tautological or context-free.
}
};

View File

@@ -0,0 +1,25 @@
'use strict';
/** Replaces a context-free food gap with a clearly answerable table setting. */
module.exports = {
async up(queryInterface) {
await queryInterface.sequelize.query(`
UPDATE community.vocab_grammar_exercise AS exercise
SET title = 'Essen & Trinken: Auf dem Tisch ergänzen',
instruction = 'Auf dem Tisch stehen Reis, Fisch und Wasser. Ergänze das fehlende Bisaya-Wort.',
question_data = '{"type":"gap_fill","text":"{gap}, isda ug tubig.","gaps":1}'::jsonb,
answer_data = '{"type":"gap_fill","answers":["Kan-on"]}'::jsonb,
explanation = 'Kan-on bedeutet gekochter Reis. Der ganze Ausdruck lautet: „Kan-on, isda ug tubig.“'
FROM community.vocab_course_lesson AS lesson
WHERE exercise.lesson_id = lesson.id
AND lesson.lesson_number = 8
AND lesson.title = 'Essen & Trinken'
AND exercise.exercise_number = 3
AND exercise.title = 'Essen & Trinken: Kernmuster ergänzen';
`);
},
async down() {
// Do not restore the ambiguous exercise.
}
};

View File

@@ -0,0 +1,25 @@
'use strict';
/** Aligns the question "Where are they?" with its stored kitchen answer. */
module.exports = {
async up(queryInterface) {
await queryInterface.sequelize.query(`
UPDATE community.vocab_grammar_exercise AS exercise
SET title = 'Personen in der Küche',
instruction = 'Jemand fragt, wo die Personen sind. Antworte, dass sie in der Küche sind.',
question_data = '{"type":"dialog_completion","question":"Welche Antwort passt?","dialog":["A: Asa sila?","B: ..."]}'::jsonb,
answer_data = '{"modelAnswer":"Naa sila sa kusina.","correct":["Naa sila sa kusina.","Naa sila sa kusina"]}'::jsonb,
explanation = '„Asa sila?“ fragt: Wo sind sie? „Naa sila sa kusina.“ bedeutet: Sie sind in der Küche.'
FROM community.vocab_course_lesson AS lesson
WHERE exercise.lesson_id = lesson.id
AND lesson.lesson_number = 12
AND lesson.title = 'Haus & Familie'
AND exercise.exercise_number = 13
AND exercise.title = 'Nach der Küche fragen';
`);
},
async down() {
// Do not restore the semantically mismatched dialogue.
}
};

View File

@@ -0,0 +1,21 @@
'use strict';
/** The expected answers include "ko", so the prompt must name first person. */
module.exports = {
async up(queryInterface) {
await queryInterface.sequelize.query(`
UPDATE community.vocab_grammar_exercise AS exercise
SET instruction = 'Bilde „ich gehe“ mit „adto“ in Vergangenheit, Gegenwart und Zukunft. Verwende in jeder Form „ko“.'
FROM community.vocab_course_lesson AS lesson
WHERE exercise.lesson_id = lesson.id
AND lesson.lesson_number = 15
AND lesson.title = 'Zeitformen - Grundlagen'
AND exercise.exercise_number = 5
AND exercise.title = 'Zeitmuster anwenden';
`);
},
async down() {
// The old instruction omitted the required person marker.
}
};

View File

@@ -0,0 +1,21 @@
'use strict';
/** The original body-part gap had several equally plausible answers. */
module.exports = {
async up(queryInterface) {
await queryInterface.sequelize.query(`
UPDATE community.vocab_grammar_exercise AS exercise
SET instruction = 'Frage auf Bisaya: „Tut dir der Bauch weh? Geht es dir schon besser?“'
FROM community.vocab_course_lesson AS lesson
WHERE exercise.lesson_id = lesson.id
AND lesson.lesson_number = 26
AND lesson.title = 'Gesundheit & Wohlbefinden'
AND exercise.exercise_number = 2
AND exercise.title = 'Gesundheitsfrage ergänzen';
`);
},
async down() {
// The old wording did not identify the intended body part.
}
};

View File

@@ -0,0 +1,19 @@
'use strict';
module.exports = {
async up(queryInterface) {
await queryInterface.sequelize.query(`
UPDATE community.vocab_grammar_exercise AS exercise
SET instruction = 'Lehne eine Einladung höflich ab: „Heute lieber nicht. Ein anderes Mal.“'
FROM community.vocab_course_lesson AS lesson
WHERE exercise.lesson_id = lesson.id
AND lesson.course_id = 1
AND lesson.lesson_number = 28
AND lesson.title = 'Höflichkeitsformen praktisch'
AND exercise.exercise_number = 2
AND exercise.title = 'Weich ablehnen';
`);
},
async down() {}
};

View File

@@ -0,0 +1,16 @@
'use strict';
module.exports = {
async up(queryInterface) {
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.party
ADD COLUMN IF NOT EXISTS relationship_id INTEGER NULL
REFERENCES falukant_data.relationship(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS party_relationship_id_idx
ON falukant_data.party (relationship_id);
`);
},
async down(queryInterface) {
await queryInterface.sequelize.query(`ALTER TABLE falukant_data.party DROP COLUMN IF EXISTS relationship_id;`);
}
};

View File

@@ -0,0 +1,19 @@
'use strict';
module.exports = {
async up(queryInterface) {
await queryInterface.sequelize.query(`
UPDATE community.vocab_grammar_exercise AS exercise
SET instruction = 'Frage: „Wo ist deine Tasche?“ Bitte danach: „Nimm deine Tasche.“'
FROM community.vocab_course_lesson AS lesson
WHERE exercise.lesson_id = lesson.id
AND lesson.course_id = 1
AND lesson.lesson_number = 30
AND lesson.title = 'Kinder & Familie'
AND exercise.exercise_number = 2
AND exercise.title = 'Kindersatz ergänzen';
`);
},
async down() {}
};

View File

@@ -0,0 +1,13 @@
'use strict';
module.exports = {
async up(queryInterface) {
await queryInterface.sequelize.query(`
DELETE FROM community.vocab_srs_item
WHERE lower(trim(learning)) = 'vergangenheit'
AND lower(trim(reference)) IN ('ni-kaon ko ganiha', 'nikaon ko ganiha');
`);
},
async down() {}
};

View File

@@ -633,6 +633,7 @@ export default function setupAssociations() {
FalukantUser.hasMany(Party, { foreignKey: 'falukantUserId', as: 'parties' }); FalukantUser.hasMany(Party, { foreignKey: 'falukantUserId', as: 'parties' });
Party.belongsTo(FalukantUser, { foreignKey: 'falukantUserId', as: 'partyUser' }); Party.belongsTo(FalukantUser, { foreignKey: 'falukantUserId', as: 'partyUser' });
Party.belongsTo(Relationship, { foreignKey: 'relationshipId', as: 'marriageRelationship' });
Party.belongsToMany(TitleOfNobility, { Party.belongsToMany(TitleOfNobility, {
through: PartyInvitedNobility, through: PartyInvitedNobility,

View File

@@ -14,6 +14,11 @@ Party.init({
allowNull: false, allowNull: false,
field: 'falukant_user_id' field: 'falukant_user_id'
}, },
relationshipId: {
type: DataTypes.INTEGER,
allowNull: true,
field: 'relationship_id'
},
musicTypeId: { musicTypeId: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false, allowNull: false,
@@ -41,4 +46,4 @@ Party.init({
timestamps: true, timestamps: true,
underscored: true}); underscored: true});
export default Party; export default Party;

View File

@@ -49,6 +49,8 @@ router.get('/dashboard-widget', falukantController.getDashboardWidget);
router.post('/family/acceptmarriageproposal', falukantController.acceptMarriageProposal); router.post('/family/acceptmarriageproposal', falukantController.acceptMarriageProposal);
router.post('/family/cancel-wooing', falukantController.cancelWooing); router.post('/family/cancel-wooing', falukantController.cancelWooing);
router.post('/family/set-heir', falukantController.setHeir); router.post('/family/set-heir', falukantController.setHeir);
router.post('/family/children/:childCharacterId/accept-marriage-proposal', falukantController.acceptChildMarriageProposal);
router.post('/family/children/:childCharacterId/advance-wooing', falukantController.advanceChildWooing);
router.post('/family/lover', falukantController.createLoverRelationship); router.post('/family/lover', falukantController.createLoverRelationship);
router.post('/family/marriage/spend-time', falukantController.spendTimeWithSpouse); router.post('/family/marriage/spend-time', falukantController.spendTimeWithSpouse);
router.post('/family/marriage/gift', falukantController.giftToSpouse); router.post('/family/marriage/gift', falukantController.giftToSpouse);

View File

@@ -1,9 +1,10 @@
import { Router } from 'express'; import { Router } from 'express';
import { authenticate } from '../middleware/authMiddleware.js';
import newsController from '../controllers/newsController.js'; import newsController from '../controllers/newsController.js';
const router = Router(); const router = Router();
router.get('/', authenticate, newsController.getNews.bind(newsController)); // News are shown on the public landing page as well as in the dashboard.
// The endpoint only exposes cached third-party headlines, no user data.
router.get('/', newsController.getNews.bind(newsController));
export default router; export default router;

View File

@@ -104,7 +104,8 @@ const LESSON_DIDACTICS = {
], ],
grammarFocus: [ grammarFocus: [
{ title: 'Bitte-Formeln mit palihug', text: '"Palihug" macht Bitten höflich und taucht in vielen Überlebenssätzen auf.', example: 'Palihug ka mubalik? / Tabangi ko, palihug.' }, { title: 'Bitte-Formeln mit palihug', text: '"Palihug" macht Bitten höflich und taucht in vielen Überlebenssätzen auf.', example: 'Palihug ka mubalik? / Tabangi ko, palihug.' },
{ title: 'Kurze Verständnisfragen', text: 'Sehr kurze Fragen helfen dir im Alltag oft mehr als lange Sätze.', example: 'Unsay pasabot ani? Asa ang CR?' } { title: 'Kurze Verständnisfragen', text: 'Sehr kurze Fragen helfen dir im Alltag oft mehr als lange Sätze.', example: 'Unsay pasabot ani? Asa ang CR?' },
{ title: 'ko und ka: ich / du im Satz', text: 'ko steht in diesen kurzen Sätzen für „ich/mich“, ka für „du/dich“. Anders als im Deutschen stehen die Formen oft nach dem wichtigen Wort oder Verb.', example: 'Wala ko kasabot. / Palihug ka mubalik? / Tabangi ko, palihug.' }
], ],
speakingPrompts: [ speakingPrompts: [
{ title: 'Wenn du etwas nicht verstehst', prompt: 'Sage, dass du etwas nicht verstehst, und bitte um Wiederholung.', cue: 'Wala ko kasabot. Palihug ka mubalik?' }, { title: 'Wenn du etwas nicht verstehst', prompt: 'Sage, dass du etwas nicht verstehst, und bitte um Wiederholung.', cue: 'Wala ko kasabot. Palihug ka mubalik?' },
@@ -245,6 +246,9 @@ const LESSON_DIDACTICS = {
{ target: 'Magpahuway ko gamay unya.', gloss: 'Ich ruhe mich später kurz aus.', alternatives: ['Ich ruhe mich später aus.'] }, { target: 'Magpahuway ko gamay unya.', gloss: 'Ich ruhe mich später kurz aus.', alternatives: ['Ich ruhe mich später aus.'] },
{ target: 'Tawagi ko kung mahuman ka.', gloss: 'Ruf mich an, wenn du fertig bist.' } { target: 'Tawagi ko kung mahuman ka.', gloss: 'Ruf mich an, wenn du fertig bist.' }
], ],
grammarFocus: [
{ title: 'imong und koy im Alltag', text: 'imong bedeutet „dein/deine“ vor einem Nomen. koy ist die zusammengezogene Form in „Naa koy …“ und drückt aus, dass du etwas hast oder vorhast.', example: 'Unsa imong buhat karon? / Naa koy lakaw karong hapon.' }
],
speakingPrompts: [ speakingPrompts: [
{ title: 'Tagesablauf abstimmen', prompt: 'Frage nach dem Plan und sage, was du heute erledigst.', cue: 'Unsa imong buhat karon? Naglimpyo ko sa balay.' } { title: 'Tagesablauf abstimmen', prompt: 'Frage nach dem Plan und sage, was du heute erledigst.', cue: 'Unsa imong buhat karon? Naglimpyo ko sa balay.' }
], ],

View File

@@ -0,0 +1,176 @@
/** Ergänzende Lektionen für Reisen und längere Aufenthalte auf Cebu. */
export const BISAYA_CEBU_TRAVEL_DIDACTICS = {
'Cebu ankommen & einchecken': {
learningGoals: [
'Bei der Ankunft höflich grüßen und einchecken.',
'Nach Zimmer und Schlüssel fragen.',
'Eine Buchung kurz bestätigen.'
],
corePatterns: [
{ target: 'Maayong adlaw.', gloss: 'Guten Tag.' },
{ target: 'Naa koy reservation.', gloss: 'Ich habe eine Reservierung.' },
{ target: 'Pwede ko mag-check in?', gloss: 'Kann ich einchecken?' },
{ target: 'Asa ang akong kwarto?', gloss: 'Wo ist mein Zimmer?' },
{ target: 'Salamat kaayo.', gloss: 'Vielen Dank.' }
],
speakingPrompts: [{
title: 'Ankunft im Hotel',
prompt: 'Begrüße die Rezeption, sage, dass du eine Reservierung hast, und frage nach deinem Zimmer.',
cue: 'Maayong adlaw. Naa koy reservation. Asa ang akong kwarto?'
}],
practicalTasks: [{
title: 'Check-in',
text: 'Führe einen kurzen Check-in mit Begrüßung, Reservierung und Zimmerfrage.'
}]
},
'Unterkunft & kleine Probleme': {
learningGoals: [
'Wichtige Wörter zur Unterkunft verstehen.',
'Freundlich nach Schlüssel, Wasser oder Handtuch fragen.',
'Ein kleines Problem im Zimmer kurz benennen.'
],
corePatterns: [
{ target: 'kwarto', gloss: 'Zimmer' },
{ target: 'yawe', gloss: 'Schlüssel' },
{ target: 'tuwalya', gloss: 'Handtuch' },
{ target: 'tubig', gloss: 'Wasser' },
{ target: 'Asa ang yawe sa kwarto?', gloss: 'Wo ist der Zimmerschlüssel?' },
{ target: 'Pwede mangayo ug tubig?', gloss: 'Kann ich um Wasser bitten?' },
{ target: 'Naay problema sa kwarto.', gloss: 'Es gibt ein Problem im Zimmer.' }
],
speakingPrompts: [{
title: 'An der Rezeption',
prompt: 'Frage nach dem Zimmerschlüssel und bitte anschließend um Wasser oder ein Handtuch.',
cue: 'Asa ang yawe sa kwarto? Pwede mangayo ug tubig?'
}],
practicalTasks: [{
title: 'Kleines Hotelproblem',
text: 'Beschreibe höflich ein kleines Problem im Zimmer und bitte um Hilfe.'
}]
},
'Restaurant & Bestellen': {
learningGoals: [
'Im Restaurant höflich bestellen.',
'Nach Wasser und Preis fragen.',
'Auf Essen freundlich reagieren.'
],
corePatterns: [
{ target: 'Gusto ko ani.', gloss: 'Ich möchte das.' },
{ target: 'Pwede mangayo ug tubig?', gloss: 'Kann ich um Wasser bitten?' },
{ target: 'Tagpila ni?', gloss: 'Wie viel kostet das?' },
{ target: 'Lami kaayo.', gloss: 'Sehr lecker.' },
{ target: 'Mubayad ko.', gloss: 'Ich möchte bezahlen.' }
],
speakingPrompts: [{
title: 'Bestellung',
prompt: 'Bestelle etwas, bitte um Wasser, frage nach dem Preis und reagiere auf das Essen.',
cue: 'Gusto ko ani. Pwede mangayo ug tubig? Tagpila ni? Lami kaayo.'
}],
practicalTasks: [{
title: 'Im Restaurant',
text: 'Führe eine kurze Bestellung vom Wunsch bis zum Bezahlen.'
}]
},
'Markt, Souvenirs & Bezahlen': {
learningGoals: [
'Am Markt nach Preis und Menge fragen.',
'Freundlich auf einen Preis reagieren.',
'Einen Einkauf abschließen.'
],
corePatterns: [
{ target: 'Pila ni tanan?', gloss: 'Wie viel kostet das alles?' },
{ target: 'Mahal ra.', gloss: 'Das ist teuer.' },
{ target: 'Barato ra.', gloss: 'Das ist günstig.' },
{ target: 'Kuhaon nako ni.', gloss: 'Ich nehme das.' },
{ target: 'Naay sukli?', gloss: 'Gibt es Wechselgeld?' }
],
speakingPrompts: [{
title: 'Souvenir kaufen',
prompt: 'Frage nach dem Gesamtpreis, reagiere freundlich und sage, dass du den Artikel nimmst.',
cue: 'Pila ni tanan? Mahal ra. Kuhaon nako ni.'
}],
practicalTasks: [{
title: 'Marktgespräch',
text: 'Führe ein kurzes, höfliches Gespräch beim Kauf eines Souvenirs.'
}]
},
'Ausflüge, Inseln & Transport': {
learningGoals: [
'Nach Hafen, Fahrt und Ausstieg fragen.',
'Einen einfachen Ausflug auf Cebu oder zu einer Insel planen.',
'Richtungs- und Transportwörter im Zusammenhang anwenden.'
],
corePatterns: [
{ target: 'Asa ang pantalan?', gloss: 'Wo ist der Hafen?' },
{ target: 'Kanus-a ang biyahe?', gloss: 'Wann ist die Fahrt?' },
{ target: 'Moadto ko sa isla.', gloss: 'Ich fahre auf die Insel.' },
{ target: 'Asa ta manaog?', gloss: 'Wo steigen wir aus?' },
{ target: 'Diretso lang.', gloss: 'Einfach geradeaus.' }
],
speakingPrompts: [{
title: 'Zum Hafen',
prompt: 'Frage nach dem Hafen und der Abfahrtszeit. Sage danach, dass du auf eine Insel fahren möchtest.',
cue: 'Asa ang pantalan? Kanus-a ang biyahe? Moadto ko sa isla.'
}],
practicalTasks: [{
title: 'Ausflug planen',
text: 'Plane einen kurzen Ausflug: Hafen finden, Fahrtzeit erfragen und richtig aussteigen.'
}]
},
'Hilfe unterwegs & Reiseprobleme': {
learningGoals: [
'In einer einfachen Problemsituation Hilfe holen.',
'Eine verlorene Tasche oder ein gesundheitliches Problem benennen.',
'Höflich und klar reagieren.'
],
corePatterns: [
{ target: 'Kinahanglan nako ug tabang.', gloss: 'Ich brauche Hilfe.' },
{ target: 'Nawala ang akong bag.', gloss: 'Meine Tasche ist verloren gegangen.' },
{ target: 'Asa ang botika?', gloss: 'Wo ist die Apotheke?' },
{ target: 'Tawag ug tabang, palihug.', gloss: 'Ruf bitte Hilfe.' },
{ target: 'Okay ra ko.', gloss: 'Mir geht es gut / Es ist in Ordnung.' }
],
speakingPrompts: [{
title: 'Unterwegs Hilfe holen',
prompt: 'Sage, dass du Hilfe brauchst, und erkläre kurz, ob du deine Tasche verloren hast oder eine Apotheke suchst.',
cue: 'Kinahanglan nako ug tabang. Nawala ang akong bag. Asa ang botika?'
}],
practicalTasks: [{
title: 'Notfall-Minirolle',
text: 'Bitte klar und höflich um Hilfe und nenne ein konkretes Problem.'
}]
},
'Cebu-Reisepfad Abschluss': {
learningGoals: [
'Die wichtigsten Reisesituationen auf Cebu zusammenhängend anwenden.',
'Bei Unterkunft, Essen, Transport und Hilfe selbstständig reagieren.',
'Höflichkeit und kurze, klare Sätze sicher einsetzen.'
],
corePatterns: [
{ target: 'Naa koy reservation.', gloss: 'Ich habe eine Reservierung.' },
{ target: 'Gusto ko ani.', gloss: 'Ich möchte das.' },
{ target: 'Asa ang pantalan?', gloss: 'Wo ist der Hafen?' },
{ target: 'Kinahanglan nako ug tabang.', gloss: 'Ich brauche Hilfe.' },
{ target: 'Amping.', gloss: 'Pass auf dich auf.' }
],
speakingPrompts: [{
title: 'Ein Reisetag auf Cebu',
prompt: 'Verbinde Unterkunft, Essen, Transport und Hilfe in einem kurzen Reisetag.',
cue: 'Naa koy reservation. Gusto ko ani. Asa ang pantalan? Amping.'
}],
practicalTasks: [{
title: 'Cebu-Reiseprobe',
text: 'Spiele einen Reisetag nach: einchecken, essen, zum Hafen fahren und bei Bedarf Hilfe holen.'
}]
}
};
export const BISAYA_CEBU_TRAVEL_LESSONS = [
{ week: 16, day: 2, num: 155, type: 'conversation', title: 'Cebu ankommen & einchecken', desc: 'Einchecken, Reservierung und Zimmerfrage auf Cebu', targetMin: 18, targetScore: 78, review: false, cultural: 'Ein höflicher Gruß und ein kurzes Salamat kaayo öffnen viele Gespräche freundlich.' },
{ week: 16, day: 2, num: 156, type: 'vocab', title: 'Unterkunft & kleine Probleme', desc: 'Zimmer, Schlüssel, Wasser und einfache Bitten an der Rezeption', targetMin: 16, targetScore: 82, review: true, cultural: null },
{ week: 16, day: 3, num: 157, type: 'conversation', title: 'Restaurant & Bestellen', desc: 'Bestellen, nach Wasser und Preis fragen und bezahlen', targetMin: 18, targetScore: 78, review: false, cultural: 'Lami kaayo ist eine warme, natürliche Reaktion auf gutes Essen.' },
{ week: 16, day: 3, num: 158, type: 'vocab', title: 'Markt, Souvenirs & Bezahlen', desc: 'Preise, Wechselgeld und höflicher Einkauf auf dem Markt', targetMin: 16, targetScore: 82, review: true, cultural: null },
{ week: 16, day: 4, num: 159, type: 'conversation', title: 'Ausflüge, Inseln & Transport', desc: 'Hafen, Fahrt und Ausstieg für Ausflüge auf Cebu', targetMin: 18, targetScore: 78, review: false, cultural: 'Bei Fahrten mit Jeepney, Bus oder Boot helfen kurze Orts- und Zeitfragen am meisten.' },
{ week: 16, day: 4, num: 160, type: 'conversation', title: 'Hilfe unterwegs & Reiseprobleme', desc: 'Tasche verloren, Apotheke finden und Hilfe holen', targetMin: 18, targetScore: 78, review: false, cultural: null },
{ week: 16, day: 5, num: 161, type: 'review', title: 'Cebu-Reisepfad Abschluss', desc: 'Prüfung und Wiederholung der wichtigsten Reisesituationen', targetMin: 22, targetScore: 82, review: false, cultural: 'Kurze, klare und höfliche Sätze sind unterwegs wirksamer als lange Erklärungen.' }
];

View File

@@ -51,6 +51,13 @@ export const BISAYA_DIDACTICS_FRAGMENTS = {
cue: 'Unsa imong buhat karon? Naglimpyo ko sa balay.' cue: 'Unsa imong buhat karon? Naglimpyo ko sa balay.'
} }
], ],
grammarFocus: [
{
title: 'imong und koy im Alltag',
text: 'imong bedeutet „dein/deine“ vor einem Nomen. koy ist die zusammengezogene Form in „Naa koy …“ und drückt aus, dass du etwas hast oder vorhast.',
example: 'Unsa imong buhat karon? / Naa koy lakaw karong hapon.'
}
],
practicalTasks: [ practicalTasks: [
{ {
title: 'Alltagscheck', title: 'Alltagscheck',

View File

@@ -18,6 +18,7 @@ import { BISAYA_DIDACTICS_24_43, BISAYA_LESSONS_24_43_BY_NUMBER, BISAYA_RELATION
import { BISAYA_PHASE3_DIDACTICS, BISAYA_PHASE3_LESSONS } from './bisaya-course-phase3-extension.js'; import { BISAYA_PHASE3_DIDACTICS, BISAYA_PHASE3_LESSONS } from './bisaya-course-phase3-extension.js';
import { BISAYA_PHASE4_DIDACTICS, BISAYA_PHASE4_LESSONS } from './bisaya-course-phase4-extension.js'; import { BISAYA_PHASE4_DIDACTICS, BISAYA_PHASE4_LESSONS } from './bisaya-course-phase4-extension.js';
import { BISAYA_PHASE5_DIDACTICS, BISAYA_PHASE5_LESSONS } from './bisaya-course-phase5-extension.js'; import { BISAYA_PHASE5_DIDACTICS, BISAYA_PHASE5_LESSONS } from './bisaya-course-phase5-extension.js';
import { BISAYA_CEBU_TRAVEL_DIDACTICS, BISAYA_CEBU_TRAVEL_LESSONS } from './bisaya-course-cebu-travel-extension.js';
function withTypeName(exerciseTypeName, exercise) { function withTypeName(exerciseTypeName, exercise) {
return { return {
@@ -33,7 +34,8 @@ const GENERATED_BISAYA_DIDACTICS = {
...BISAYA_DIDACTICS_24_43, ...BISAYA_DIDACTICS_24_43,
...BISAYA_PHASE3_DIDACTICS, ...BISAYA_PHASE3_DIDACTICS,
...BISAYA_PHASE4_DIDACTICS, ...BISAYA_PHASE4_DIDACTICS,
...BISAYA_PHASE5_DIDACTICS ...BISAYA_PHASE5_DIDACTICS,
...BISAYA_CEBU_TRAVEL_DIDACTICS
}; };
const SAFE_EXERCISE_UPDATE_TITLES = new Set([ const SAFE_EXERCISE_UPDATE_TITLES = new Set([
@@ -49,7 +51,8 @@ const SAFE_EXERCISE_UPDATE_TITLES = new Set([
'Bitten & Fragen', 'Bitten & Fragen',
...BISAYA_PHASE3_LESSONS.map((lesson) => lesson.title), ...BISAYA_PHASE3_LESSONS.map((lesson) => lesson.title),
...BISAYA_PHASE4_LESSONS.map((lesson) => lesson.title), ...BISAYA_PHASE4_LESSONS.map((lesson) => lesson.title),
...BISAYA_PHASE5_LESSONS.map((lesson) => lesson.title) ...BISAYA_PHASE5_LESSONS.map((lesson) => lesson.title),
...BISAYA_CEBU_TRAVEL_LESSONS.map((lesson) => lesson.title)
]); ]);
function normalizeText(value) { function normalizeText(value) {
@@ -516,6 +519,14 @@ function getChoiceQuestion(lesson, didactics) {
} }
} }
function getConcreteChoiceQuestion(lesson, didactics, pattern) {
const normalizedPattern = normalizeText(pattern).toLocaleLowerCase('de');
if (normalizedPattern === 'mas maayo na ka?') {
return 'Eine Person war krank. Du möchtest fragen, ob es ihr inzwischen besser geht. Welche Formulierung passt?';
}
return getChoiceQuestion(lesson, didactics);
}
function pickDistractors(pattern, allPatterns, count) { function pickDistractors(pattern, allPatterns, count) {
return allPatterns return allPatterns
.filter((entry) => entry !== pattern) .filter((entry) => entry !== pattern)
@@ -536,7 +547,7 @@ function buildChoiceExercise(lesson, didactics, pattern, allPatterns, variant =
// used as the question for a single-answer multiple-choice exercise. // used as the question for a single-answer multiple-choice exercise.
const question = normalizedPattern?.gloss const question = normalizedPattern?.gloss
? `Wie sagt man auf Bisaya: „${normalizedPattern.gloss}“?` ? `Wie sagt man auf Bisaya: „${normalizedPattern.gloss}“?`
: getChoiceQuestion(lesson, didactics); : getConcreteChoiceQuestion(lesson, didactics, pattern);
return { return {
exerciseTypeId: 2, exerciseTypeId: 2,
@@ -599,7 +610,8 @@ function buildGapExercise(lessonTitle, pattern) {
function buildContextGapExercise(lesson, didactics, pattern) { function buildContextGapExercise(lesson, didactics, pattern) {
const speakingPrompt = Array.isArray(didactics.speakingPrompts) ? didactics.speakingPrompts[0] : null; const speakingPrompt = Array.isArray(didactics.speakingPrompts) ? didactics.speakingPrompts[0] : null;
const cue = normalizeText(speakingPrompt?.cue || '').toLowerCase(); const scenarioCue = normalizeText(speakingPrompt?.cue || '');
const cue = scenarioCue.toLowerCase();
// A contextual prompt must test a pattern that actually occurs in its // A contextual prompt must test a pattern that actually occurs in its
// scenario. Otherwise a cultural keyword such as "respeto" can be paired // scenario. Otherwise a cultural keyword such as "respeto" can be paired
// with an invitation-declining situation. // with an invitation-declining situation.
@@ -608,14 +620,26 @@ function buildContextGapExercise(lesson, didactics, pattern) {
.find((candidate) => candidate && cue.includes(normalizeText(candidate).toLowerCase())); .find((candidate) => candidate && cue.includes(normalizeText(candidate).toLowerCase()));
// Do not hide a complete sentence behind one blank. Learners need enough // Do not hide a complete sentence behind one blank. Learners need enough
// visible context to know which word or short phrase belongs in the gap. // visible context to know which word or short phrase belongs in the gap.
const gapExercise = scenarioPattern const scenarioSteps = scenarioCue
? buildGapExercise(lesson.title, scenarioPattern) .split(/[.!?]+/)
: buildGapExercise(lesson.title, pattern); .map((step) => step.trim())
.filter(Boolean);
const cueWordCount = scenarioCue
.replace(/[.,?!]/g, ' ')
.split(/\s+/)
.filter(Boolean)
.length;
// A multi-step cue or a short word sequence is already the concrete
// scenario. Do not reduce a route or a table setting to a bare word.
const gapTarget = scenarioSteps.length >= 2 || cueWordCount >= 3
? scenarioCue
: (scenarioPattern || pattern);
const gapExercise = buildGapExercise(lesson.title, gapTarget);
if (!gapExercise) return null; if (!gapExercise) return null;
return { return {
...gapExercise, ...gapExercise,
title: `${lesson.title}: Kernmuster ergänzen`, title: `${lesson.title}: Satz im Kontext ergänzen`,
instruction: `Vervollständige die Formulierung passend zur Situation: ${getScenarioPrompt(lesson, didactics)}` instruction: `Vervollständige die Formulierung passend zur Situation: ${getScenarioPrompt(lesson, didactics)}`
}; };
} }
@@ -634,7 +658,7 @@ function buildSentenceExercise(lessonTitle, pattern) {
instruction: 'Ordne die Wörter zu einem korrekten Bisaya-Satz.', instruction: 'Ordne die Wörter zu einem korrekten Bisaya-Satz.',
questionData: { questionData: {
type: 'sentence_building', type: 'sentence_building',
question: `Baue das Kernmuster aus der Lektion "${lessonTitle}".`, question: 'Ordne diese Wörter zu einem vollständigen Bisaya-Satz.',
tokens tokens
}, },
answerData: { answerData: {
@@ -1090,7 +1114,7 @@ const BISAYA_EXERCISES = {
{ {
exerciseTypeId: 1, exerciseTypeId: 1,
title: 'Gesundheitsfrage ergänzen', title: 'Gesundheitsfrage ergänzen',
instruction: 'Fülle die Lücken mit den passenden Bisaya-Wörtern.', instruction: 'Frage auf Bisaya: „Tut dir der Bauch weh? Geht es dir schon besser?“',
questionData: { questionData: {
type: 'gap_fill', type: 'gap_fill',
text: 'Sakit imong {gap}? Mas maayo na {gap}?', text: 'Sakit imong {gap}? Mas maayo na {gap}?',
@@ -1236,7 +1260,7 @@ const BISAYA_EXERCISES = {
{ {
exerciseTypeId: 1, exerciseTypeId: 1,
title: 'Weich ablehnen', title: 'Weich ablehnen',
instruction: 'Fülle die Lücken.', instruction: 'Lehne eine Einladung höflich ab: „Heute lieber nicht. Ein anderes Mal.“',
questionData: { questionData: {
type: 'gap_fill', type: 'gap_fill',
text: 'Dili lang sa {gap}. Sunod na {gap}.', text: 'Dili lang sa {gap}. Sunod na {gap}.',
@@ -1318,7 +1342,7 @@ const BISAYA_EXERCISES = {
{ {
exerciseTypeId: 1, exerciseTypeId: 1,
title: 'Nachfragen ergänzen', title: 'Nachfragen ergänzen',
instruction: 'Fülle die Lücken.', instruction: 'Frage: „Wo ist deine Tasche?“ Bitte danach: „Nimm deine Tasche.“',
questionData: { questionData: {
type: 'gap_fill', type: 'gap_fill',
text: 'Hinay-hinay {gap}. Unsay pasabot {gap}?', text: 'Hinay-hinay {gap}. Unsay pasabot {gap}?',
@@ -2413,23 +2437,21 @@ const BISAYA_EXERCISES = {
explanation: '„Naa sila sa …“ = Sie sind in/am …' explanation: '„Naa sila sa …“ = Sie sind in/am …'
}, },
withTypeName('dialog_completion', { withTypeName('dialog_completion', {
title: 'Nach der Küche fragen', title: 'Personen in der Küche',
instruction: 'Ergänze die passende Antwort (Ortsangaben wie didto/luyo kommen in der nächsten Lektion).', instruction: 'Jemand fragt, wo die Personen sind. Antworte, dass sie in der Küche sind.',
questionData: { questionData: {
type: 'dialog_completion', type: 'dialog_completion',
question: 'Welche Antwort passt?', question: 'Welche Antwort passt?',
dialog: ['A: Asa ang kusina?', 'B: ...'] dialog: ['A: Asa sila?', 'B: ...']
}, },
answerData: { answerData: {
modelAnswer: 'Naa sila sa kusina.', modelAnswer: 'Naa sila sa kusina.',
correct: [ correct: [
'Naa sila sa kusina.', 'Naa sila sa kusina.',
'Naa siya sa kusina.', 'Naa sila sa kusina'
'Naa ko sa kusina.',
'Sa kusina.'
] ]
}, },
explanation: 'Mit „Naa … sa kusina“ kannst du sagen, wo sich jemand befindet dasselbe Muster wie „Naa sila sa kusina.“ in der Lektion.' explanation: '„Asa sila?“ fragt: Wo sind sie? „Naa sila sa kusina.“ bedeutet: Sie sind in der Küche.'
}), }),
withTypeName('situational_response', { withTypeName('situational_response', {
title: 'Zu Hause kurz beschreiben', title: 'Zu Hause kurz beschreiben',
@@ -2696,7 +2718,7 @@ const BISAYA_EXERCISES = {
}, },
withTypeName('gap_fill', { withTypeName('gap_fill', {
title: 'Zeitmuster anwenden', title: 'Zeitmuster anwenden',
instruction: 'Verwende das Verb "adto" in Vergangenheit, Gegenwart und Zukunft. Trage jede Form in das passende Feld ein.', instruction: 'Bilde „ich gehe“ mit „adto in Vergangenheit, Gegenwart und Zukunft. Verwende in jeder Form „ko“.',
questionData: { questionData: {
type: 'gap_fill', type: 'gap_fill',
text: 'Vergangenheit: {gap} | Gegenwart: {gap} | Zukunft: {gap}', text: 'Vergangenheit: {gap} | Gegenwart: {gap} | Zukunft: {gap}',
@@ -3560,6 +3582,18 @@ const BISAYA_EXERCISES = {
}, },
explanation: '"Wala ko kasabot" = "Ich verstehe nicht", "Palihug ka mubalik?" = "Bitte wiederholen".' explanation: '"Wala ko kasabot" = "Ich verstehe nicht", "Palihug ka mubalik?" = "Bitte wiederholen".'
}, },
{
exerciseTypeId: 1,
title: 'ko oder ka? erste Personenformen',
instruction: 'Ergänze ko oder ka. Lies jeden Satz anschließend laut.',
questionData: {
type: 'gap_fill',
text: 'Wala {gap} kasabot. (Ich verstehe nicht.) | Palihug {gap} mubalik? (Kannst du bitte wiederholen?) | Tabangi {gap}, palihug. (Hilf mir bitte.)',
gaps: 3
},
answerData: { type: 'gap_fill', answers: ['ko', 'ka', 'ko'] },
explanation: 'ko steht hier für „ich/mich“: Wala ko kasabot, Tabangi ko. ka richtet sich an die andere Person: Palihug ka mubalik?'
},
{ {
exerciseTypeId: 4, exerciseTypeId: 4,
title: '„Wo ist die Toilette?" übersetzen', title: '„Wo ist die Toilette?" übersetzen',
@@ -3686,6 +3720,18 @@ const BISAYA_EXERCISES = {
// Lektion 11: Alltagsgespräche - Teil 1 (~2030 Min Übungsmaterial) // Lektion 11: Alltagsgespräche - Teil 1 (~2030 Min Übungsmaterial)
'Alltagsgespräche - Teil 1': [ 'Alltagsgespräche - Teil 1': [
{
exerciseTypeId: 1,
title: 'imong oder koy? Besitz und eigener Plan',
instruction: 'Ergänze die passende kurze Form.',
questionData: {
type: 'gap_fill',
text: 'Unsa {gap} buhat karon? (Was machst du heute?) | Naa {gap} lakaw karong hapon. (Ich habe heute Nachmittag etwas zu erledigen.)',
gaps: 2
},
answerData: { type: 'gap_fill', answers: ['imong', 'koy'] },
explanation: 'imong heißt „dein/deine“ und steht vor buhat. koy ist die zusammengezogene Form in Naa koy …: „Ich habe / bei mir ist …“. '
},
{ {
exerciseTypeId: 2, // multiple_choice exerciseTypeId: 2, // multiple_choice
title: 'Wie sagt man "Wie war dein Tag?"?', title: 'Wie sagt man "Wie war dein Tag?"?',

View File

@@ -16,6 +16,7 @@ import { BISAYA_DIDACTICS_24_43, BISAYA_LESSONS_24_43, BISAYA_RELATIONSHIP_ANCHO
import { BISAYA_PHASE3_DIDACTICS, BISAYA_PHASE3_LESSONS } from './bisaya-course-phase3-extension.js'; import { BISAYA_PHASE3_DIDACTICS, BISAYA_PHASE3_LESSONS } from './bisaya-course-phase3-extension.js';
import { BISAYA_PHASE4_DIDACTICS, BISAYA_PHASE4_LESSONS } from './bisaya-course-phase4-extension.js'; import { BISAYA_PHASE4_DIDACTICS, BISAYA_PHASE4_LESSONS } from './bisaya-course-phase4-extension.js';
import { BISAYA_PHASE5_DIDACTICS, BISAYA_PHASE5_LESSONS } from './bisaya-course-phase5-extension.js'; import { BISAYA_PHASE5_DIDACTICS, BISAYA_PHASE5_LESSONS } from './bisaya-course-phase5-extension.js';
import { BISAYA_CEBU_TRAVEL_DIDACTICS, BISAYA_CEBU_TRAVEL_LESSONS } from './bisaya-course-cebu-travel-extension.js';
const LESSON_DIDACTICS = { const LESSON_DIDACTICS = {
'Begrüßungen & Höflichkeit': { 'Begrüßungen & Höflichkeit': {
@@ -173,6 +174,11 @@ const LESSON_DIDACTICS = {
title: 'Kurze Verständnisfragen', title: 'Kurze Verständnisfragen',
text: 'Sehr kurze Fragen helfen dir im Alltag oft mehr als lange Sätze.', text: 'Sehr kurze Fragen helfen dir im Alltag oft mehr als lange Sätze.',
example: 'Unsay pasabot ani? Asa ang CR?' example: 'Unsay pasabot ani? Asa ang CR?'
},
{
title: 'ko und ka: ich / du im Satz',
text: 'ko steht in diesen kurzen Sätzen für „ich/mich“, ka für „du/dich“. Anders als im Deutschen stehen die Formen oft nach dem wichtigen Wort oder Verb.',
example: 'Wala ko kasabot. / Palihug ka mubalik? / Tabangi ko, palihug.'
} }
], ],
speakingPrompts: [ speakingPrompts: [
@@ -373,7 +379,7 @@ const LESSON_DIDACTICS = {
speakingPrompts: [ speakingPrompts: [
{ {
title: 'Auf dem Tisch', title: 'Auf dem Tisch',
prompt: 'Nenne drei Dinge, die auf dem Tisch stehen oder die du essen und trinken möchtest.', prompt: 'Auf dem Tisch stehen Reis, Fisch und Wasser. Ergänze das fehlende Bisaya-Wort.',
cue: 'Kan-on, isda ug tubig.' cue: 'Kan-on, isda ug tubig.'
} }
], ],
@@ -400,6 +406,13 @@ const LESSON_DIDACTICS = {
{ target: 'Magpahuway ko gamay unya.', gloss: 'Ich ruhe mich später kurz aus.', alternatives: ['Ich ruhe mich später aus.'] }, { target: 'Magpahuway ko gamay unya.', gloss: 'Ich ruhe mich später kurz aus.', alternatives: ['Ich ruhe mich später aus.'] },
{ target: 'Tawagi ko kung mahuman ka.', gloss: 'Ruf mich an, wenn du fertig bist.' } { target: 'Tawagi ko kung mahuman ka.', gloss: 'Ruf mich an, wenn du fertig bist.' }
], ],
grammarFocus: [
{
title: 'imong und koy im Alltag',
text: 'imong bedeutet „dein/deine“ vor einem Nomen. koy ist die zusammengezogene Form in „Naa koy …“ und drückt aus, dass du etwas hast oder vorhast.',
example: 'Unsa imong buhat karon? / Naa koy lakaw karong hapon.'
}
],
speakingPrompts: [ speakingPrompts: [
{ {
title: 'Tagesablauf abstimmen', title: 'Tagesablauf abstimmen',
@@ -868,7 +881,8 @@ const LESSON_DIDACTICS = {
...BISAYA_DIDACTICS_24_43, ...BISAYA_DIDACTICS_24_43,
...BISAYA_PHASE3_DIDACTICS, ...BISAYA_PHASE3_DIDACTICS,
...BISAYA_PHASE4_DIDACTICS, ...BISAYA_PHASE4_DIDACTICS,
...BISAYA_PHASE5_DIDACTICS ...BISAYA_PHASE5_DIDACTICS,
...BISAYA_CEBU_TRAVEL_DIDACTICS
}; };
const LESSONS = [ const LESSONS = [
@@ -993,10 +1007,11 @@ const LESSONS = [
...BISAYA_PHASE3_LESSONS, ...BISAYA_PHASE3_LESSONS,
...BISAYA_PHASE4_LESSONS, ...BISAYA_PHASE4_LESSONS,
...BISAYA_PHASE5_LESSONS ...BISAYA_PHASE5_LESSONS,
...BISAYA_CEBU_TRAVEL_LESSONS
]; ];
const BISAYA_FAMILY_COURSE_TITLE = 'Bisaya für Familien - Alltag & Stabilisierung'; const BISAYA_FAMILY_COURSE_TITLE = 'Bisaya für Cebu Reise, Familie & Alltag';
async function createBisayaCourse(languageId, ownerHashedId) { async function createBisayaCourse(languageId, ownerHashedId) {
try { try {
@@ -1034,7 +1049,7 @@ async function createBisayaCourse(languageId, ownerHashedId) {
const course = await VocabCourse.create({ const course = await VocabCourse.create({
ownerUserId: user.id, ownerUserId: user.id,
title: BISAYA_FAMILY_COURSE_TITLE, title: BISAYA_FAMILY_COURSE_TITLE,
description: 'Lerne Bisaya (Cebuano) praxisnah für den Familienalltag. Der Pfad verbindet Schnellstart, Alltagsmodule und Stabilisierungsblöcke mit Spiralwiederholung, Fehlertraining und freier Produktion.', description: 'Lerne Bisaya (Cebuano) praxisnah für Reisen und längere Aufenthalte auf Cebu mit Unterkunft, Essen, Markt, Ausflügen, Familie, Höflichkeit und Hilfe unterwegs.',
languageId: Number(languageId), languageId: Number(languageId),
difficultyLevel: 1, difficultyLevel: 1,
isPublic: true, isPublic: true,

View File

@@ -0,0 +1,69 @@
#!/usr/bin/env node
/** Fügt Bisaya-Kursen den optionalen Reisepfad „Cebu Reise & Alltag“ hinzu. */
import { sequelize } from '../utils/sequelize.js';
import VocabCourse from '../models/community/vocab_course.js';
import VocabCourseLesson from '../models/community/vocab_course_lesson.js';
import { BISAYA_CEBU_TRAVEL_DIDACTICS, BISAYA_CEBU_TRAVEL_LESSONS } from './bisaya-course-cebu-travel-extension.js';
async function extendBisayaCourseForCebuTravel() {
await sequelize.authenticate();
const [language] = await sequelize.query(
`SELECT id FROM community.vocab_language WHERE name = 'Bisaya' LIMIT 1`,
{ type: sequelize.QueryTypes.SELECT }
);
if (!language) throw new Error('Bisaya-Sprache nicht gefunden.');
const courses = await VocabCourse.findAll({ where: { languageId: language.id } });
let addedLessons = 0;
for (const course of courses) {
await course.update({
title: 'Bisaya für Cebu Reise, Familie & Alltag',
description: 'Praxisnahes Cebuano für Reisen und längere Aufenthalte auf Cebu: Unterkunft, Essen, Markt, Ausflüge, Familie, Höflichkeit und Hilfe unterwegs.'
});
for (const lessonData of BISAYA_CEBU_TRAVEL_LESSONS) {
const existing = await VocabCourseLesson.findOne({
where: { courseId: course.id, lessonNumber: lessonData.num }
});
if (existing) continue;
const didactics = BISAYA_CEBU_TRAVEL_DIDACTICS[lessonData.title];
await VocabCourseLesson.create({
courseId: course.id,
chapterId: null,
lessonNumber: lessonData.num,
title: lessonData.title,
description: lessonData.desc,
weekNumber: lessonData.week,
dayNumber: lessonData.day,
lessonType: lessonData.type,
culturalNotes: lessonData.cultural,
learningGoals: didactics.learningGoals,
corePatterns: didactics.corePatterns,
grammarFocus: didactics.grammarFocus || [],
speakingPrompts: didactics.speakingPrompts || [],
practicalTasks: didactics.practicalTasks || [],
targetMinutes: lessonData.targetMin,
targetScorePercent: lessonData.targetScore,
requiresReview: lessonData.review,
didacticMode: 'travel_practice',
phaseLabel: 'Cebu Reise & Alltag',
blockNumber: 8,
difficultyWeight: 1,
newUnitTarget: 8,
reviewWeight: lessonData.review ? 2 : 1,
isIntensiveReview: false
});
addedLessons += 1;
console.log(`✅ Kurs ${course.id}: Lektion ${lessonData.num} ${lessonData.title} ergänzt`);
}
}
console.log(`Cebu-Reisepfad: ${addedLessons} Lektionen ergänzt.`);
}
extendBisayaCourseForCebuTravel()
.then(() => sequelize.close())
.catch((error) => {
console.error('❌ Fehler:', error);
sequelize.close();
process.exitCode = 1;
});

View File

@@ -27,6 +27,7 @@ const SAFE_SYNC_STEPS = {
'backend/scripts/extend-bisaya-course-phase3.js', 'backend/scripts/extend-bisaya-course-phase3.js',
'backend/scripts/extend-bisaya-course-phase4.js', 'backend/scripts/extend-bisaya-course-phase4.js',
'backend/scripts/extend-bisaya-course-phase5.js', 'backend/scripts/extend-bisaya-course-phase5.js',
'backend/scripts/extend-bisaya-course-cebu-travel.js',
// Alte Zusatzlektionen 44/45 teilen Nummern mit Phase 3. Erst der // Alte Zusatzlektionen 44/45 teilen Nummern mit Phase 3. Erst der
// Content-Schritt stellt ihre sichtbaren Zahlentitel wieder her; danach // Content-Schritt stellt ihre sichtbaren Zahlentitel wieder her; danach
// kann die Didaktik die Zahlmuster statt der Besuchsmuster einspielen. // kann die Didaktik die Zahlmuster statt der Besuchsmuster einspielen.

View File

@@ -19,6 +19,7 @@ import {
import { BISAYA_PHASE3_DIDACTICS } from './bisaya-course-phase3-extension.js'; import { BISAYA_PHASE3_DIDACTICS } from './bisaya-course-phase3-extension.js';
import { BISAYA_PHASE4_DIDACTICS } from './bisaya-course-phase4-extension.js'; import { BISAYA_PHASE4_DIDACTICS } from './bisaya-course-phase4-extension.js';
import { BISAYA_PHASE5_DIDACTICS } from './bisaya-course-phase5-extension.js'; import { BISAYA_PHASE5_DIDACTICS } from './bisaya-course-phase5-extension.js';
import { BISAYA_CEBU_TRAVEL_DIDACTICS } from './bisaya-course-cebu-travel-extension.js';
/** Alte Kurstitel → aktueller Schlüssel in LESSON_DIDACTICS (bestehende Datenbanken). */ /** Alte Kurstitel → aktueller Schlüssel in LESSON_DIDACTICS (bestehende Datenbanken). */
export const LEGACY_DIDACTICS_TITLE_MAP = { export const LEGACY_DIDACTICS_TITLE_MAP = {
@@ -135,7 +136,8 @@ export const LESSON_DIDACTICS = {
], ],
grammarFocus: [ grammarFocus: [
{ title: 'Bitte-Formeln mit palihug', text: '"Palihug" macht Bitten höflich und taucht in vielen Überlebenssätzen auf.', example: 'Palihug ka mubalik? / Tabangi ko, palihug.' }, { title: 'Bitte-Formeln mit palihug', text: '"Palihug" macht Bitten höflich und taucht in vielen Überlebenssätzen auf.', example: 'Palihug ka mubalik? / Tabangi ko, palihug.' },
{ title: 'Kurze Verständnisfragen', text: 'Sehr kurze Fragen helfen dir im Alltag oft mehr als lange Sätze.', example: 'Unsay pasabot ani? Asa ang CR?' } { title: 'Kurze Verständnisfragen', text: 'Sehr kurze Fragen helfen dir im Alltag oft mehr als lange Sätze.', example: 'Unsay pasabot ani? Asa ang CR?' },
{ title: 'ko und ka: ich / du im Satz', text: 'ko steht in diesen kurzen Sätzen für „ich/mich“, ka für „du/dich“. Anders als im Deutschen stehen die Formen oft nach dem wichtigen Wort oder Verb.', example: 'Wala ko kasabot. / Palihug ka mubalik? / Tabangi ko, palihug.' }
], ],
speakingPrompts: [ speakingPrompts: [
{ title: 'Wenn du etwas nicht verstehst', prompt: 'Sage, dass du etwas nicht verstehst, und bitte um Wiederholung.', cue: 'Wala ko kasabot. Palihug ka mubalik?' }, { title: 'Wenn du etwas nicht verstehst', prompt: 'Sage, dass du etwas nicht verstehst, und bitte um Wiederholung.', cue: 'Wala ko kasabot. Palihug ka mubalik?' },
@@ -501,7 +503,8 @@ export const LESSON_DIDACTICS = {
...BISAYA_DIDACTICS_24_43, ...BISAYA_DIDACTICS_24_43,
...BISAYA_PHASE3_DIDACTICS, ...BISAYA_PHASE3_DIDACTICS,
...BISAYA_PHASE4_DIDACTICS, ...BISAYA_PHASE4_DIDACTICS,
...BISAYA_PHASE5_DIDACTICS ...BISAYA_PHASE5_DIDACTICS,
...BISAYA_CEBU_TRAVEL_DIDACTICS
}; };
function resolveDidacticsForLesson(lesson) { function resolveDidacticsForLesson(lesson) {

View File

@@ -94,9 +94,9 @@ function calcAge(birthdate) {
// Ein realer Tag entspricht einem Falukant-Spieljahr. // Ein realer Tag entspricht einem Falukant-Spieljahr.
const FAMILY_AGE = Object.freeze({ const FAMILY_AGE = Object.freeze({
MIN_WOOING: 12, MIN_WOOING: 10,
MIN_MARRIAGE: 14, MIN_MARRIAGE: 14,
MIN_HOUSEHOLD_AND_CHILDREN: 16, MIN_HOUSEHOLD_AND_CHILDREN: 14,
}); });
async function getFalukantUserOrFail(hashedId) { async function getFalukantUserOrFail(hashedId) {
@@ -3820,7 +3820,7 @@ class FalukantService extends BaseService {
const childChars = childCharIds.length const childChars = childCharIds.length
? await FalukantCharacter.findAll({ ? await FalukantCharacter.findAll({
where: { id: childCharIds }, where: { id: childCharIds },
attributes: ['id', 'birthdate', 'gender'], attributes: ['id', 'birthdate', 'gender', 'regionId', 'titleOfNobility'],
include: [{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }] include: [{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }]
}) })
: []; : [];
@@ -3892,6 +3892,42 @@ class FalukantService extends BaseService {
otherParent: otherParentId != null ? (otherParentMap[otherParentId] || null) : null, otherParent: otherParentId != null ? (otherParentMap[otherParentId] || null) : null,
}; };
}); });
const childIds = children.map((child) => child.childCharacterId);
const childRelationships = childIds.length
? await Relationship.findAll({
where: { [Op.or]: [{ character1Id: { [Op.in]: childIds } }, { character2Id: { [Op.in]: childIds } }] },
include: [
{ model: RelationshipType, as: 'relationshipType', attributes: ['tr'] },
{ model: FalukantCharacter, as: 'character1', attributes: ['id'], include: [{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }] },
{ model: FalukantCharacter, as: 'character2', attributes: ['id'], include: [{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }] },
],
attributes: ['id', 'character1Id', 'character2Id', 'nextStepProgress']
})
: [];
const childRelationshipMap = new Map();
for (const relation of childRelationships) {
const type = relation.relationshipType?.tr;
if (!['wooing', 'engaged', 'married'].includes(type)) continue;
const childId = childIds.includes(relation.character1Id) ? relation.character1Id : relation.character2Id;
const partner = relation.character1Id === childId ? relation.character2 : relation.character1;
childRelationshipMap.set(childId, {
id: relation.id,
status: type,
progress: relation.nextStepProgress || 0,
partnerName: partner?.definedFirstName?.name || 'Unbekannt',
});
}
for (const child of children) {
child.relationship = childRelationshipMap.get(child.childCharacterId) || null;
child.possiblePartners = [];
const kid = childCharMap[child.childCharacterId];
if (!child.hasName || child.relationship || !kid || calcAge(kid.birthdate) < FAMILY_AGE.MIN_WOOING) continue;
child.possiblePartners = await this.getPossiblePartners(kid.id);
if (child.possiblePartners.length === 0) {
await this.createPossiblePartners(kid.id, kid.gender, kid.regionId, kid.titleOfNobility, calcAge(kid.birthdate));
child.possiblePartners = await this.getPossiblePartners(kid.id);
}
}
// Sort children globally by relation createdAt ascending (older first) // Sort children globally by relation createdAt ascending (older first)
children.sort((a, b) => new Date(a._createdAt) - new Date(b._createdAt)); children.sort((a, b) => new Date(a._createdAt) - new Date(b._createdAt));
const inProgress = ['wooing', 'engaged', 'married']; const inProgress = ['wooing', 'engaged', 'married'];
@@ -4637,7 +4673,7 @@ class FalukantService extends BaseService {
gender: { [Op.ne]: requestingCharacterGender }, gender: { [Op.ne]: requestingCharacterGender },
regionId: requestingRegionId, regionId: requestingRegionId,
birthdate: { [Op.lte]: new Date(Date.now() - FAMILY_AGE.MIN_WOOING * 24 * 60 * 60 * 1000) }, birthdate: { [Op.lte]: new Date(Date.now() - FAMILY_AGE.MIN_WOOING * 24 * 60 * 60 * 1000) },
createdAt: { [Op.lt]: new Date(new Date() - 12 * 24 * 60 * 60 * 1000) }, createdAt: { [Op.lt]: new Date(Date.now() - FAMILY_AGE.MIN_WOOING * 24 * 60 * 60 * 1000) },
titleOfNobility: { [Op.between]: [requestingCharacterTitleOfNobility - 1, requestingCharacterTitleOfNobility + 1] } titleOfNobility: { [Op.between]: [requestingCharacterTitleOfNobility - 1, requestingCharacterTitleOfNobility + 1] }
}, },
order: [ order: [
@@ -4684,7 +4720,7 @@ class FalukantService extends BaseService {
|| calcAge(character.birthdate) < FAMILY_AGE.MIN_WOOING || calcAge(character.birthdate) < FAMILY_AGE.MIN_WOOING
|| calcAge(proposedCharacter.birthdate) < FAMILY_AGE.MIN_WOOING || calcAge(proposedCharacter.birthdate) < FAMILY_AGE.MIN_WOOING
) { ) {
const error = new Error('Werbung ist erst ab 12 Spieljahren möglich'); const error = new Error('Werbung ist erst ab 10 Spieljahren möglich');
error.status = 422; error.status = 422;
throw error; throw error;
} }
@@ -4723,6 +4759,60 @@ class FalukantService extends BaseService {
return { success: true, message: 'Marriage proposal accepted', relationshipId: newRel.id }; return { success: true, message: 'Marriage proposal accepted', relationshipId: newRel.id };
} }
async acceptChildMarriageProposal(hashedUserId, childCharacterId, proposedCharacterId) {
const user = await this.getFalukantUserByHashedId(hashedUserId);
const childId = Number(childCharacterId);
const partnerId = Number(proposedCharacterId);
if (!Number.isInteger(childId) || !Number.isInteger(partnerId)) throw { status: 400, message: 'Ungültige Partnerauswahl' };
const childRelation = await ChildRelation.findOne({
where: { childCharacterId: childId, [Op.or]: [{ fatherCharacterId: user.character.id }, { motherCharacterId: user.character.id }] }
});
if (!childRelation) throw { status: 404, message: 'Kind gehört nicht zu deinem Charakter' };
const [child, partner, proposal, activeRelation] = await Promise.all([
FalukantCharacter.findByPk(childId, { attributes: ['id', 'birthdate'] }),
FalukantCharacter.findByPk(partnerId, { attributes: ['id', 'birthdate'] }),
MarriageProposal.findOne({ where: { requesterCharacterId: childId, proposedCharacterId: partnerId } }),
Relationship.findOne({ where: { [Op.or]: [{ character1Id: childId }, { character2Id: childId }] }, include: [{ model: RelationshipType, as: 'relationshipType', where: { tr: { [Op.in]: ['wooing', 'engaged', 'married'] } } }] })
]);
if (!proposal || !child || !partner) throw { status: 404, message: 'Partnervorschlag nicht gefunden' };
if (activeRelation) throw { status: 409, message: 'Das Kind ist bereits in einer Beziehung' };
if (calcAge(child.birthdate) < FAMILY_AGE.MIN_WOOING || calcAge(partner.birthdate) < FAMILY_AGE.MIN_WOOING) {
throw { status: 422, message: 'Verkuppelung ist erst ab 10 Spieljahren möglich' };
}
if (Number(user.money) < Number(proposal.cost)) throw { status: 422, message: 'Nicht genügend Guthaben' };
const wooingType = await RelationshipType.findOne({ where: { tr: 'wooing' } });
const moneyResult = await updateFalukantUserMoney(user.id, -proposal.cost, 'Child marriage arrangement', user.id);
if (!moneyResult.success) throw new Error('Geld konnte nicht abgezogen werden');
const relationship = await Relationship.create({
character1Id: childId,
character2Id: partnerId,
relationshipTypeId: wooingType.id,
nextStepProgress: Math.ceil(FalukantService.WOOING_PROGRESS_TARGET / 2),
});
await MarriageProposal.destroy({ where: { requesterCharacterId: childId } });
return { success: true, relationshipId: relationship.id };
}
async advanceChildWooing(hashedUserId, childCharacterId) {
const user = await this.getFalukantUserByHashedId(hashedUserId);
const childId = Number(childCharacterId);
const isOwnChild = await ChildRelation.findOne({
where: { childCharacterId: childId, [Op.or]: [{ fatherCharacterId: user.character.id }, { motherCharacterId: user.character.id }] },
attributes: ['childCharacterId']
});
if (!isOwnChild) throw { status: 404, message: 'Kind gehört nicht zu deinem Charakter' };
const relationship = await Relationship.findOne({
where: { [Op.or]: [{ character1Id: childId }, { character2Id: childId }] },
include: [{ model: RelationshipType, as: 'relationshipType', where: { tr: 'wooing' } }]
});
if (!relationship) throw { status: 409, message: 'Keine laufende Werbung für dieses Kind' };
const engagedType = await RelationshipType.findOne({ where: { tr: 'engaged' } });
await relationship.update({ nextStepProgress: 0, relationshipTypeId: engagedType.id });
return { success: true, status: 'engaged' };
}
async cancelWooing(hashedUserId) { async cancelWooing(hashedUserId) {
const user = await this.getFalukantUserByHashedId(hashedUserId); const user = await this.getFalukantUserByHashedId(hashedUserId);
if (!user || !user.character) { if (!user || !user.character) {
@@ -5608,44 +5698,37 @@ class FalukantService extends BaseService {
}; };
} }
async getWeddingCandidates(falukantUser) {
const parent = await FalukantCharacter.findOne({ where: { userId: falukantUser.id }, attributes: ['id'] });
if (!parent) return [];
const childRelations = await ChildRelation.findAll({
where: { [Op.or]: [{ fatherCharacterId: parent.id }, { motherCharacterId: parent.id }] },
attributes: ['childCharacterId']
});
const familyCharacterIds = [parent.id, ...childRelations.map((relation) => relation.childCharacterId)];
const relationships = await Relationship.findAll({
where: { [Op.or]: [{ character1Id: { [Op.in]: familyCharacterIds } }, { character2Id: { [Op.in]: familyCharacterIds } }] },
include: [
{ model: RelationshipType, as: 'relationshipType', where: { tr: 'engaged' } },
{ model: FalukantCharacter, as: 'character1', attributes: ['id', 'birthdate'], include: [{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }] },
{ model: FalukantCharacter, as: 'character2', attributes: ['id', 'birthdate'], include: [{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }] },
],
attributes: ['id', 'character1Id', 'character2Id']
});
return relationships
.filter((relationship) => calcAge(relationship.character1.birthdate) >= FAMILY_AGE.MIN_MARRIAGE
&& calcAge(relationship.character2.birthdate) >= FAMILY_AGE.MIN_MARRIAGE)
.map((relationship) => ({
relationshipId: relationship.id,
label: `${relationship.character1.definedFirstName?.name || 'Unbekannt'} & ${relationship.character2.definedFirstName?.name || 'Unbekannt'}`,
}));
}
async getPartyTypes(hashedUserId) { async getPartyTypes(hashedUserId) {
const falukantUser = await getFalukantUserOrFail(hashedUserId); const falukantUser = await getFalukantUserOrFail(hashedUserId);
const character = await FalukantCharacter.findOne({ const weddingCandidates = await this.getWeddingCandidates(falukantUser);
where: { userId: falukantUser.id },
attributes: ['id', 'birthdate'],
});
const engagedCount = character && calcAge(character.birthdate) >= FAMILY_AGE.MIN_MARRIAGE
? await Relationship.count({
include: [
{
model: RelationshipType,
as: 'relationshipType',
where: { tr: 'engaged' },
required: true
},
{
model: FalukantCharacter,
as: 'character1',
where: { userId: falukantUser.id },
required: false
},
{
model: FalukantCharacter,
as: 'character2',
where: { userId: falukantUser.id },
required: false
}
],
where: {
[Op.or]: [
{ '$character1.user_id$': falukantUser.id },
{ '$character2.user_id$': falukantUser.id }
]
}
})
: 0;
const orConditions = [{ forMarriage: false }]; const orConditions = [{ forMarriage: false }];
if (engagedCount > 0) { if (weddingCandidates.length > 0) {
orConditions.push({ forMarriage: true }); orConditions.push({ forMarriage: true });
} }
const partyTypes = await PartyType.findAll({ const partyTypes = await PartyType.findAll({
@@ -5656,10 +5739,10 @@ class FalukantService extends BaseService {
}); });
const musicTypes = await MusicType.findAll(); const musicTypes = await MusicType.findAll();
const banquetteTypes = await BanquetteType.findAll(); const banquetteTypes = await BanquetteType.findAll();
return { partyTypes, musicTypes, banquetteTypes }; return { partyTypes, musicTypes, banquetteTypes, weddingCandidates };
} }
async createParty(hashedUserId, partyTypeId, musicId, banquetteId, nobilityIds = [], servantRatio) { async createParty(hashedUserId, partyTypeId, musicId, banquetteId, nobilityIds = [], servantRatio, relationshipId = null) {
const falukantUser = await getFalukantUserOrFail(hashedUserId); const falukantUser = await getFalukantUserOrFail(hashedUserId);
const since = new Date(Date.now() - 24 * 3600 * 1000); const since = new Date(Date.now() - 24 * 3600 * 1000);
const already = await Party.findOne({ const already = await Party.findOne({
@@ -5694,34 +5777,9 @@ class FalukantService extends BaseService {
const character = await FalukantCharacter.findOne({ where: { userId: falukantUser.id }, attributes: ['id', 'birthdate', 'titleOfNobility'] }); const character = await FalukantCharacter.findOne({ where: { userId: falukantUser.id }, attributes: ['id', 'birthdate', 'titleOfNobility'] });
if (ptype.forMarriage) { if (ptype.forMarriage) {
if (!character || calcAge(character.birthdate) < FAMILY_AGE.MIN_MARRIAGE) { const validRelationshipIds = (await this.getWeddingCandidates(falukantUser)).map((candidate) => candidate.relationshipId);
const error = new Error('Eine Hochzeit ist erst ab 14 Spieljahren möglich'); if (!validRelationshipIds.includes(Number(relationshipId))) {
error.status = 422; const error = new Error('Für eine Hochzeitsfeier muss ein verlobtes Paar ab 14 Spieljahren ausgewählt werden');
throw error;
}
const engagement = await Relationship.findOne({
where: {
[Op.or]: [
{ character1Id: character.id },
{ character2Id: character.id },
],
},
include: [{ model: RelationshipType, as: 'relationshipType', where: { tr: 'engaged' } }],
attributes: ['character1Id', 'character2Id'],
});
const partnerId = engagement?.character1Id === character.id
? engagement.character2Id
: engagement?.character1Id;
const partner = partnerId
? await FalukantCharacter.findByPk(partnerId, { attributes: ['birthdate'] })
: null;
if (!engagement) {
const error = new Error('Für eine Hochzeitsfeier musst du mit deinem Partner verlobt sein');
error.status = 422;
throw error;
}
if (!partner || calcAge(partner.birthdate) < FAMILY_AGE.MIN_MARRIAGE) {
const error = new Error('Beide Verlobten müssen mindestens 14 Spieljahre alt sein');
error.status = 422; error.status = 422;
throw error; throw error;
} }
@@ -5748,6 +5806,7 @@ class FalukantService extends BaseService {
falukantUserId: falukantUser.id, falukantUserId: falukantUser.id,
musicTypeId: musicId, musicTypeId: musicId,
banquetteTypeId: banquetteId, banquetteTypeId: banquetteId,
relationshipId: ptype.forMarriage ? Number(relationshipId) : null,
servantRatio, servantRatio,
cost: cost cost: cost
}); });
@@ -5796,24 +5855,14 @@ class FalukantService extends BaseService {
async getNotBaptisedChildren(hashedUserId) { async getNotBaptisedChildren(hashedUserId) {
const falukantUser = await getFalukantUserOrFail(hashedUserId); const falukantUser = await getFalukantUserOrFail(hashedUserId);
const userCharacterIds = (await FalukantCharacter.findAll({
where: { userId: falukantUser.id },
attributes: ['id'],
raw: true,
})).map((character) => character.id);
if (userCharacterIds.length === 0) return [];
const children = await ChildRelation.findAll({ const children = await ChildRelation.findAll({
include: [ include: [
{
model: FalukantCharacter,
as: 'father',
where: {
userId: falukantUser.id,
},
required: false,
},
{
model: FalukantCharacter,
as: 'mother',
where: {
userId: falukantUser.id,
},
required: false,
},
{ {
model: FalukantCharacter, model: FalukantCharacter,
as: 'child', as: 'child',
@@ -5829,6 +5878,10 @@ class FalukantService extends BaseService {
], ],
where: { where: {
nameSet: false, nameSet: false,
[Op.or]: [
{ fatherCharacterId: { [Op.in]: userCharacterIds } },
{ motherCharacterId: { [Op.in]: userCharacterIds } },
],
}, },
order: [['createdAt', 'DESC']], order: [['createdAt', 'DESC']],
}); });

View File

@@ -104,20 +104,21 @@ async function getCachedNews({ language = 'de', category = 'top', minArticles =
* @param {number} options.counter - Index des Artikels (0 = erster, 1 = zweiter, …) * @param {number} options.counter - Index des Artikels (0 = erster, 1 = zweiter, …)
* @param {string} [options.language] * @param {string} [options.language]
* @param {string} [options.category] * @param {string} [options.category]
* @param {number} [options.count] - Anzahl aufeinanderfolgender Artikel
* @returns {Promise<{ results: Array, nextPage: string|null }>} * @returns {Promise<{ results: Array, nextPage: string|null }>}
*/ */
async function getNews({ counter = 0, language = 'de', category = 'top' }) { async function getNews({ counter = 0, count = 1, language = 'de', category = 'top' }) {
const neededIndex = Math.max(0, counter); const neededIndex = Math.max(0, counter);
const requestedCount = Math.min(6, Math.max(1, Number.parseInt(count, 10) || 1));
// Mindestens so viele Artikel laden wie benötigt // Mindestens so viele Artikel laden wie benötigt
const articles = await getCachedNews({ const articles = await getCachedNews({
language, language,
category, category,
minArticles: neededIndex + 1 minArticles: neededIndex + requestedCount
}); });
const single = articles[neededIndex] ? [articles[neededIndex]] : []; return { results: articles.slice(neededIndex, neededIndex + requestedCount), nextPage: null };
return { results: single, nextPage: null };
} }
export default { getNews }; export default { getNews };

View File

@@ -2918,6 +2918,33 @@ export default class VocabService {
}; };
} }
_buildSyntheticLexemeGapExercisePlain(lessonId, row, exerciseNumber) {
const learning = String(row.learning || '').trim();
const reference = String(row.reference || '').trim();
if (!learning || !reference) {
return null;
}
// A short contextual tail makes multi-word entries a real gap exercise,
// while single words still have a useful translation cue.
const parts = reference.split(/\s+/).filter(Boolean);
const text = parts.length > 1
? `{gap} ${parts.slice(1).join(' ')} (${learning})`
: `{gap} (${learning})`;
return {
id: `syn-gap-${lessonId}-${row.id}-l2r`,
lessonId,
exerciseTypeId: 1,
exerciseType: { id: 1, name: 'gap_fill' },
exerciseNumber,
title: `Fehlendes Wort: ${learning}`,
instruction: 'Ergänze das fehlende Wort.',
questionData: { type: 'gap_fill', text },
answerData: { type: 'gap_fill', answers: [parts[0]] },
explanation: null,
createdByUserId: 0
};
}
async _fetchChapterLexemeRowsForMc(chapterId) { async _fetchChapterLexemeRowsForMc(chapterId) {
const id = Number.parseInt(chapterId, 10); const id = Number.parseInt(chapterId, 10);
if (!Number.isFinite(id)) { if (!Number.isFinite(id)) {
@@ -2951,26 +2978,9 @@ export default class VocabService {
if (plainLesson.lessonType === 'review' || plainLesson.lessonType === 'vocab_review' || plainLesson.lessonType === 'weekly_review') { if (plainLesson.lessonType === 'review' || plainLesson.lessonType === 'vocab_review' || plainLesson.lessonType === 'weekly_review') {
return list; return list;
} }
let rows = []; // Synthetic IDs are checked against the chapter lexeme table. Do not use
// extracted, temporary weekly rows here: they have no stable ID to verify.
// If this lesson belongs to a week, prefer vocab from previous lessons of the same week const rows = await this._fetchChapterLexemeRowsForMc(plainLesson.chapterId);
if (plainLesson.weekNumber) {
try {
const weekExercises = await this._getWeekVocabExercises(plainLesson.courseId, plainLesson.weekNumber, plainLesson.lessonNumber);
const extracted = this._extractTrainerVocabsFromExercises(weekExercises || [], { allowGapFill: false });
if (extracted && extracted.length) {
// Map extracted pairs to row-like objects with numeric ids
rows = extracted.map((item, idx) => ({ id: 2000000 + idx, learning: item.learning, reference: item.reference }));
}
} catch (err) {
// ignore and fallback to chapter lexemes
rows = [];
}
}
if (!rows.length) {
rows = await this._fetchChapterLexemeRowsForMc(plainLesson.chapterId);
}
if (!rows.length) { if (!rows.length) {
plainLesson.chapterLexemeExamCount = 0; plainLesson.chapterLexemeExamCount = 0;
@@ -3044,6 +3054,19 @@ export default class VocabService {
} }
} }
// Add active-recall questions as well as recognition questions. A smaller
// sample keeps a chapter test manageable while ensuring every test contains
// several genuine "which word is missing?" tasks.
const gapTarget = Math.min(8, Math.max(3, Math.ceil(examSelected.length * 0.5)));
const gapRows = this._seededShuffle(examSelected.slice(), (seed + 7919) >>> 0).slice(0, gapTarget);
for (const row of gapRows) {
maxNum += 1;
const ex = this._buildSyntheticLexemeGapExercisePlain(plainLesson.id, row, maxNum);
if (ex) {
list.push(ex);
}
}
return list; return list;
} }
@@ -3121,6 +3144,43 @@ export default class VocabService {
}; };
} }
async _checkSyntheticLexemeGapAnswer(user, lessonId, chapterLexemeId, userAnswer) {
const lesson = await VocabCourseLesson.findByPk(lessonId, {
include: [{ model: VocabCourse, as: 'course' }]
});
if (!lesson || (lesson.course.ownerUserId !== user.id && !lesson.course.isPublic)) {
const err = new Error('Exercise not found');
err.status = lesson ? 403 : 404;
throw err;
}
const enrollment = await VocabCourseEnrollment.findOne({
where: { userId: user.id, courseId: lesson.courseId }
});
if (!enrollment || !lesson.chapterId) {
const err = new Error('Exercise not found');
err.status = enrollment ? 404 : 403;
throw err;
}
const rows = await this._fetchChapterLexemeRowsForMc(lesson.chapterId);
const row = rows.find((entry) => Number(entry.id) === Number(chapterLexemeId));
const referenceParts = String(row?.reference || '').trim().split(/\s+/).filter(Boolean);
if (!referenceParts.length) {
const err = new Error('Exercise not found');
err.status = 404;
throw err;
}
const correctAnswer = referenceParts[0];
const submitted = Array.isArray(userAnswer) ? userAnswer[0] : userAnswer;
const correct = this._isEquivalentAnswer(submitted, correctAnswer);
return {
correct,
correctAnswer,
alternatives: [],
explanation: null,
progress: { attempts: 1, correctAttempts: correct ? 1 : 0, lastAttemptAt: new Date(), completed: correct, completedAt: correct ? new Date() : null }
};
}
async getLesson(hashedUserId, lessonId) { async getLesson(hashedUserId, lessonId) {
const user = await this._getUserByHashedId(hashedUserId); const user = await this._getUserByHashedId(hashedUserId);
const lesson = await VocabCourseLesson.findByPk(lessonId, { const lesson = await VocabCourseLesson.findByPk(lessonId, {
@@ -3617,7 +3677,7 @@ export default class VocabService {
const seed = (Number(lessonId) * 100003) >>> 0; const seed = (Number(lessonId) * 100003) >>> 0;
const percentage = 40 + (seed % 21); const percentage = 40 + (seed % 21);
const targetCount = Math.max(1, Math.ceil((list.length * percentage) / 100)); const targetCount = Math.max(1, Math.ceil((list.length * percentage) / 100));
return this._seededShuffle(list.slice(), seed).slice(0, targetCount); return this._selectConcreteExamExercises(list, targetCount, seed);
} }
_selectCheckpointExamExercises(exercises = [], lessonId) { _selectCheckpointExamExercises(exercises = [], lessonId) {
@@ -3628,7 +3688,73 @@ export default class VocabService {
// Checkpoints: smaller sample ~10-30% // Checkpoints: smaller sample ~10-30%
const percentage = 10 + (seed % 21); const percentage = 10 + (seed % 21);
const targetCount = Math.max(1, Math.ceil((list.length * percentage) / 100)); const targetCount = Math.max(1, Math.ceil((list.length * percentage) / 100));
return this._seededShuffle(list.slice(), seed).slice(0, targetCount); return this._selectConcreteExamExercises(list, targetCount, seed);
}
_getExamExerciseQuestionType(exercise) {
const questionData = typeof exercise?.questionData === 'string'
? JSON.parse(exercise.questionData)
: (exercise?.questionData || {});
return String(questionData.type || exercise?.exerciseType?.name || '').trim().toLowerCase();
}
_isGenericPatternPrompt(exercise) {
const questionData = typeof exercise?.questionData === 'string'
? JSON.parse(exercise.questionData)
: (exercise?.questionData || {});
const prompt = String(questionData.question || questionData.text || exercise?.instruction || '');
return /(?:kernmuster|zentrales? muster)/i.test(prompt);
}
/**
* Review and checkpoint exams must be concrete and reliably checkable.
* Prefer gap fills (one is included whenever available), then multiple
* choice, and use open "core pattern" prompts only when there is no other
* material available at all.
*/
_selectConcreteExamExercises(exercises = [], targetCount, seed) {
const list = Array.isArray(exercises) ? exercises : [];
const groups = {
gap: [],
multipleChoice: [],
concreteOther: [],
fallback: []
};
for (const exercise of list) {
const type = this._getExamExerciseQuestionType(exercise);
if (type === 'gap_fill') {
groups.gap.push(exercise);
} else if (type === 'multiple_choice') {
groups.multipleChoice.push(exercise);
} else if (!this._isGenericPatternPrompt(exercise)
&& !['speaking_from_memory', 'reading_aloud'].includes(type)) {
groups.concreteOther.push(exercise);
} else {
groups.fallback.push(exercise);
}
}
const selected = [];
const addUntilFull = (items, groupSeed) => {
for (const exercise of this._seededShuffle(items, groupSeed)) {
if (selected.length >= targetCount) break;
selected.push(exercise);
}
};
// A gap fill is a good, unambiguous opening question and avoids exams
// consisting only of abstract sentence-building prompts. Keep the rest
// balanced so a larger exam does not become a list of gaps only.
const shuffledGaps = this._seededShuffle(groups.gap, (seed ^ 0x1f123bb5) >>> 0);
if (shuffledGaps.length > 0) {
selected.push(shuffledGaps.shift());
}
addUntilFull(groups.multipleChoice, (seed ^ 0x39a1f8c7) >>> 0);
addUntilFull(shuffledGaps, (seed ^ 0x2d98c6f1) >>> 0);
addUntilFull(groups.concreteOther, (seed ^ 0x5bd1e995) >>> 0);
addUntilFull(groups.fallback, (seed ^ 0x7f4a7c15) >>> 0);
return selected;
} }
/** /**
@@ -4417,6 +4543,15 @@ export default class VocabService {
userAnswer userAnswer
); );
} }
const synGapMatch = /^syn-gap-(\d+)-(\d+)-l2r$/.exec(exIdStr);
if (synGapMatch) {
return this._checkSyntheticLexemeGapAnswer(
user,
Number(synGapMatch[1]),
Number(synGapMatch[2]),
userAnswer
);
}
const exercise = await VocabGrammarExercise.findByPk(exerciseId, { const exercise = await VocabGrammarExercise.findByPk(exerciseId, {
include: [ include: [
{ model: VocabCourseLesson, as: 'lesson', include: [{ model: VocabCourse, as: 'course' }] } { model: VocabCourseLesson, as: 'lesson', include: [{ model: VocabCourse, as: 'course' }] }

View File

@@ -7,28 +7,34 @@ verwendet daher die Differenz zwischen `CURRENT_DATE` und `birthdate` in Tagen.
| Bereich | Mindestalter | Regel | | Bereich | Mindestalter | Regel |
| --- | ---: | --- | | --- | ---: | --- |
| Werbung | 12 | Partner können vorgeschlagen und ein Vorschlag kann angenommen werden. | | Werbung | 10 | Partner können vorgeschlagen und ein Vorschlag kann angenommen werden. |
| Hochzeit | 14 | Eine Verlobung kann durch eine Hochzeitsfeier vollzogen werden. | | Hochzeit | 14 | Eine Verlobung kann durch eine Hochzeitsfeier vollzogen werden. |
| Gemeinsamer Haushalt und Kinder | 16 | Erst dann darf die Spielmechanik eine Schwangerschaft oder Geburt aus einer Ehe erzeugen. | | Gemeinsamer Haushalt und Kinder | 14 | Ab dem Heiratsalter darf die Spielmechanik eine Schwangerschaft oder Geburt aus einer Ehe erzeugen. |
Die Regeln gelten für **beide** Personen einer Beziehung. Sie bilden bewusst Die Regeln gelten für **beide** Personen einer Beziehung. Sie bilden bewusst
Spielphasen ab; es gibt keine individuelle Prüfung körperlicher Reife und keine Spielphasen ab; es gibt keine individuelle Prüfung körperlicher Reife und keine
explizite Sexualmechanik. explizite Sexualmechanik.
Eltern können ihre benannten Kinder ab 10 Spieljahren verkuppeln. Diese
Werbung ist verkürzt: Nach der Auswahl eines Kandidaten genügt ein weiterer
Verkuppelungsschritt zur Verlobung. Die Hochzeitsfeier bleibt trotzdem ab 14
Spieljahren erforderlich und wird dabei ausdrücklich dem verlobten Paar
zugeordnet. Ab diesem Alter kann die normale automatische Kinderlogik greifen.
## Bereits im Backend und Daemon umgesetzt ## Bereits im Backend und Daemon umgesetzt
- `backend/services/falukantService.js` - `backend/services/falukantService.js`
- erzeugt Heiratsvorschläge nur für mindestens 12 Jahre alte Partner; - erzeugt Heiratsvorschläge nur für mindestens 10 Jahre alte Partner;
- prüft beim Annehmen eines Vorschlags beide Alter erneut; - prüft beim Annehmen eines Vorschlags beide Alter erneut;
- bietet den Hochzeitstyp erst ab 14 an und prüft beim Bestellen der Feier - bietet den Hochzeitstyp erst ab 14 an und prüft beim Bestellen der Feier
beide Verlobte serverseitig; beide Verlobte serverseitig;
- plant die Hochzeits-Schwangerschaft erst, wenn beide mindestens 16 sind. - plant die Hochzeits-Schwangerschaft erst, wenn beide mindestens 14 sind.
- `src/valuerecalculationworker.h` - `src/valuerecalculationworker.h`
- vollzieht die Hochzeit nach der mindestens einen Tag alten Hochzeitsfeier - vollzieht die Hochzeit nach der mindestens einen Tag alten Hochzeitsfeier
nur, wenn beide Verlobte mindestens 14 sind. nur, wenn beide Verlobte mindestens 14 sind.
- `src/usercharacterworker.h` - `src/usercharacterworker.h`
- berücksichtigt für die bestehende zufällige Ehe-Kinderlogik ausschließlich - berücksichtigt für die bestehende zufällige Ehe-Kinderlogik ausschließlich
Paare, bei denen beide mindestens 16 sind. Paare, bei denen beide mindestens 14 sind.
## Auftrag für den externen Daemon ## Auftrag für den externen Daemon
@@ -55,13 +61,13 @@ Jeder automatische Empfängnis- oder Geburtenkandidat muss beide Bedingungen
erfüllen: erfüllen:
```sql ```sql
mother.birthdate <= CURRENT_DATE - INTERVAL '16 days' mother.birthdate <= CURRENT_DATE - INTERVAL '14 days'
AND father.birthdate <= CURRENT_DATE - INTERVAL '16 days' AND father.birthdate <= CURRENT_DATE - INTERVAL '14 days'
``` ```
Das gilt sowohl für die Zufallslogik als auch für einen geplanten Das gilt sowohl für die Zufallslogik als auch für einen geplanten
`pregnancy_due_at`-Pfad. Beim geplanten Pfad soll der Daemon die Felder nicht `pregnancy_due_at`-Pfad. Beim geplanten Pfad soll der Daemon die Felder nicht
leeren, solange mindestens ein Elternteil noch unter 16 ist; die Schwangerschaft leeren, solange mindestens ein Elternteil noch unter 14 ist; die Schwangerschaft
wird erst verarbeitet, sobald beide die Grenze erreicht haben. Admin-Tools wird erst verarbeitet, sobald beide die Grenze erreicht haben. Admin-Tools
können weiterhin ein bewusstes, separat protokolliertes Override anbieten. können weiterhin ein bewusstes, separat protokolliertes Override anbieten.
@@ -69,8 +75,8 @@ können weiterhin ein bewusstes, separat protokolliertes Override anbieten.
- 13/13 Jahre, verlobt, Hochzeitsfeier älter als 24 Stunden: bleibt `engaged`. - 13/13 Jahre, verlobt, Hochzeitsfeier älter als 24 Stunden: bleibt `engaged`.
- 14/14 Jahre, verlobt, Hochzeitsfeier älter als 24 Stunden: wird `married`. - 14/14 Jahre, verlobt, Hochzeitsfeier älter als 24 Stunden: wird `married`.
- Verheiratet, ein Elternteil 15: keine automatische Schwangerschaft/Geburt. - Verheiratet, ein Elternteil 13: keine automatische Schwangerschaft/Geburt.
- Verheiratet, beide 16: normaler Schwangerschafts-/Geburtspfad ist möglich. - Verheiratet, beide 14: normaler Schwangerschafts-/Geburtspfad ist möglich.
## Bestehende Daten ## Bestehende Daten

4
falukant-models.env Normal file
View File

@@ -0,0 +1,4 @@
# Versioned Falukant source-model release. Keep both values together when
# publishing a new release attachment.
FALUKANT_MODELS_URL=https://git.tsschulz.de/torsten/yourpart3-assets/releases/download/falukant-models-v1/falukant-models-v1.tar.gz
FALUKANT_MODELS_SHA256=b7e4c168c3e3fb3b17d73e142472da13b7084e01cccdea24add087c7991f1fe2

View File

@@ -207,10 +207,17 @@ export default {
.app-section-bar__back { .app-section-bar__back {
flex: 0 0 auto; flex: 0 0 auto;
background: rgba(255, 255, 255, 0.82); background: rgba(255, 255, 255, 0.82);
color: var(--color-text-primary);
box-shadow: none; box-shadow: none;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
} }
.app-section-bar__back:hover:not(:disabled) {
background: var(--color-secondary-soft);
color: var(--color-text-primary);
box-shadow: none;
}
@media (max-width: 760px) { @media (max-width: 760px) {
.app-section-bar { .app-section-bar {
flex-direction: column; flex-direction: column;

View File

@@ -140,6 +140,7 @@ import apiClient from '@/utils/axios.js';
import { normalizeComparableWithNumberWords } from '@/utils/numberAnswerVariants.js'; import { normalizeComparableWithNumberWords } from '@/utils/numberAnswerVariants.js';
const PRACTICE_MIN_EXPOSURES = 3; const PRACTICE_MIN_EXPOSURES = 3;
// The original daily batch remains stable when the dialog is reopened.
const SRS_SESSION_STORAGE_VERSION = 2; const SRS_SESSION_STORAGE_VERSION = 2;
const HARD_REQUIRED_CONSECUTIVE_CORRECT = 5; const HARD_REQUIRED_CONSECUTIVE_CORRECT = 5;
const MAX_DAILY_DUE = 50; const MAX_DAILY_DUE = 50;
@@ -502,10 +503,7 @@ export default {
const dueIds = (this.pool || []).map((it) => it.id); const dueIds = (this.pool || []).map((it) => it.id);
const stored = this.loadSrsSession(); const stored = this.loadSrsSession();
// If the previous session is already complete, start fresh (new batch of due items). if (stored) {
if (stored && Number(stored.initialTotalDue || 0) > 0 && Array.isArray(stored.doneIds) && stored.doneIds.length >= stored.initialTotalDue) {
this.srsSession = null;
} else if (stored) {
this.srsSession = stored; this.srsSession = stored;
} else { } else {
this.srsSession = null; this.srsSession = null;
@@ -543,16 +541,23 @@ export default {
try { this.saveSrsSession(); } catch (_) {} try { this.saveSrsSession(); } catch (_) {}
} }
const initialDueIds = Array.isArray(this.srsSession.initialDueIds)
? this.srsSession.initialDueIds.slice(0, MAX_DAILY_DUE)
: [];
const doneSet = new Set(Array.isArray(this.srsSession.doneIds) ? this.srsSession.doneIds : []); const doneSet = new Set(Array.isArray(this.srsSession.doneIds) ? this.srsSession.doneIds : []);
const finalReviewIds = Array.isArray(this.srsSession.finalReviewIds) ? this.srsSession.finalReviewIds : []; const finalReviewIds = Array.isArray(this.srsSession.finalReviewIds) ? this.srsSession.finalReviewIds : [];
const finalReviewSet = new Set(finalReviewIds); const finalReviewSet = new Set(finalReviewIds);
const availableIds = new Set(dueIds); const availableIds = new Set(dueIds);
// Never fill a partly completed daily batch with additional overdue
// cards. The 50 cards chosen when the session began remain today's
// batch; answered cards reduce its remaining count.
this.srsQueueIds = [ this.srsQueueIds = [
...dueIds.filter((id) => !doneSet.has(id) && !finalReviewSet.has(id)), ...initialDueIds.filter((id) => availableIds.has(id) && !doneSet.has(id) && !finalReviewSet.has(id)),
...finalReviewIds.filter((id) => !doneSet.has(id) && availableIds.has(id)) ...finalReviewIds.filter((id) => initialDueIds.includes(id) && !doneSet.has(id) && availableIds.has(id))
]; ];
// Fallback: if SRS mode but queue is empty (e.g. mismatch between stored session and current pool), fall back to pool order // Only a corrupted session without an original batch may use the pool
if (this.srsMode && Array.isArray(this.srsQueueIds) && this.srsQueueIds.length === 0) { // as fallback. An empty, completed batch must remain empty today.
if (this.srsMode && initialDueIds.length === 0) {
const fallbackIds = (this.pool || []).map((it) => it.id).filter(Boolean); const fallbackIds = (this.pool || []).map((it) => it.id).filter(Boolean);
if (fallbackIds.length > 0) { if (fallbackIds.length > 0) {
const limited = fallbackIds.slice(0, MAX_DAILY_DUE); const limited = fallbackIds.slice(0, MAX_DAILY_DUE);

View File

@@ -80,6 +80,13 @@
"title": "Sugdi na", "title": "Sugdi na",
"text": "Pwede na nimo gamiton, sulayan ug hatagan og feedback. Pagrehistro pinaagi sa “{register}” o sugdi ang random chat." "text": "Pwede na nimo gamiton, sulayan ug hatagan og feedback. Pagrehistro pinaagi sa “{register}” o sugdi ang random chat."
}, },
"news": {
"kicker": "Bag-ong balita",
"title": "Balita",
"loading": "Gikarga ang balita …",
"empty": "Walay balita nga anaa karon.",
"unavailable": "Dili magamit ang balita karon."
},
"languageTrainerSeo": { "languageTrainerSeo": {
"title": "Language trainers para sa adlaw-adlaw (beginner)", "title": "Language trainers para sa adlaw-adlaw (beginner)",
"introBefore": "Ang YourPart adunay duha ka", "introBefore": "Ang YourPart adunay duha ka",

View File

@@ -447,6 +447,7 @@
"courseFlowIntensiveStatusAction": "Tan-awa ang sunod nga balik-balik", "courseFlowIntensiveStatusAction": "Tan-awa ang sunod nga balik-balik",
"courseFlowIntensiveStatusTitle": "Sunod nga balik-balik sa bokabularyo", "courseFlowIntensiveStatusTitle": "Sunod nga balik-balik sa bokabularyo",
"courseFlowIntensiveStatusAllDone": "Wala pay dugang nga balik-balik sa bokabularyo nga nakaplano alang sa nahuman nga mga leksiyon.", "courseFlowIntensiveStatusAllDone": "Wala pay dugang nga balik-balik sa bokabularyo nga nakaplano alang sa nahuman nga mga leksiyon.",
"courseFlowIntensiveStatusDue": "Nahibilin karon: {remaining} sa {quota} giplano · kinatibuk-ang angay: {total}",
"courseFlowIntensiveStatusProgress": "{count} ka termino", "courseFlowIntensiveStatusProgress": "{count} ka termino",
"courseFlowIntensiveStatusNoDate": "Ang mga petsa nagsalig sa imong sakto nga tubag: 1, 3, 7, 14, 30, 60 ug 120 ka adlaw. Ang lisod nga mga termino gilain ug praktisahon sa lugar sa lisod nga mga termino.", "courseFlowIntensiveStatusNoDate": "Ang mga petsa nagsalig sa imong sakto nga tubag: 1, 3, 7, 14, 30, 60 ug 120 ka adlaw. Ang lisod nga mga termino gilain ug praktisahon sa lugar sa lisod nga mga termino.",
"courseFlowPracticeTitle": "Libre nga pagpalalom", "courseFlowPracticeTitle": "Libre nga pagpalalom",
@@ -765,9 +766,9 @@
"quickReviewPromptTarget": "Type sa target pinulongan: \"{term}\"", "quickReviewPromptTarget": "Type sa target pinulongan: \"{term}\"",
"quickReviewAcknowledge": "Read, continue", "quickReviewAcknowledge": "Read, continue",
"courseTodayPlanIntroNoDueReview": "Walay angay nga mubo nga balik-balik karon. Makita nimo ang sunod nga makatarunganon nga lakang sa block (limitado sa kabug-aton), unya ang intensive kung naay. Ang mubo nga balik-balik mobalik sa 1/3/7 ka adlaw.", "courseTodayPlanIntroNoDueReview": "Walay angay nga mubo nga balik-balik karon. Makita nimo ang sunod nga makatarunganon nga lakang sa block (limitado sa kabug-aton), unya ang intensive kung naay. Ang mubo nga balik-balik mobalik sa 1/3/7 ka adlaw.",
"srsDueStat": "SRS angay: {count}", "srsDueStat": "Adlaw-adlaw nga balik-balik: {scheduled} sa {total} ka termino",
"srsEyebrow": "Dugay nga memorya", "srsEyebrow": "Dugay nga memorya",
"srsTitle": "{count} ka termino ang angay karon", "srsTitle": "Karon: {scheduled} sa {total} ka angay balik-balikon",
"srsIntro": "Kini nga balik-balik gikan sa SRS nga plano sa matag pulong. Una kini kaysa bag-ong materyal kay nagpalig-on kini sa mga pulong nga hapit malimtan.", "srsIntro": "Kini nga balik-balik gikan sa SRS nga plano sa matag pulong. Una kini kaysa bag-ong materyal kay nagpalig-on kini sa mga pulong nga hapit malimtan.",
"srsStart": "Sugdi ang daily review", "srsStart": "Sugdi ang daily review",
"courseTodayPlanIntroSrs": "Didaktik nga han-ay: una ang SRS daily review sa tagsa-tagsa ka pulong. Human ana, mubo nga review, padayon sa block, ug intensive review kung kinahanglan. Mao ni ang pagpalig-on sa daan nga materyal sa dili pa modugang ug bag-o." "courseTodayPlanIntroSrs": "Didaktik nga han-ay: una ang SRS daily review sa tagsa-tagsa ka pulong. Human ana, mubo nga review, padayon sa block, ug intensive review kung kinahanglan. Mao ni ang pagpalig-on sa daan nga materyal sa dili pa modugang ug bag-o."

View File

@@ -775,6 +775,20 @@
"setAsHeir": "Als Erben festlegen", "setAsHeir": "Als Erben festlegen",
"heirSetSuccess": "Das Kind wurde erfolgreich als Erbe festgelegt.", "heirSetSuccess": "Das Kind wurde erfolgreich als Erbe festgelegt.",
"heirSetError": "Fehler beim Festlegen des Erben.", "heirSetError": "Fehler beim Festlegen des Erben.",
"matchmaking": "Verkuppelung",
"selectPartner": "Partner auswählen",
"arrange": "Werbung arrangieren",
"arrangeSuccess": "Die Werbung des Kindes wurde arrangiert.",
"arrangeError": "Die Verkuppelung konnte nicht durchgeführt werden.",
"completeWooing": "Werbung abschließen",
"engagedSuccess": "Das Kind ist nun verlobt.",
"weddingHint": "Hochzeitsfeier ab 14 Jahren möglich.",
"noPartnerProposal": "Noch keine passenden Vorschläge.",
"relationshipStatus": {
"wooing": "Werbung läuft",
"engaged": "Verlobt",
"married": "Verheiratet"
},
"actions": "Aktionen", "actions": "Aktionen",
"none": "Keine Kinder vorhanden.", "none": "Keine Kinder vorhanden.",
"detailButton": "Details anzeigen", "detailButton": "Details anzeigen",

View File

@@ -80,6 +80,13 @@
"title": "Mitmachen", "title": "Mitmachen",
"text": "Du kannst die Plattform bereits nutzen, testen und Feedback geben. Registriere dich über „{register}“ oder starte unverbindlich den RandomChat." "text": "Du kannst die Plattform bereits nutzen, testen und Feedback geben. Registriere dich über „{register}“ oder starte unverbindlich den RandomChat."
}, },
"news": {
"kicker": "Aktuelles",
"title": "News",
"loading": "News werden geladen …",
"empty": "Zurzeit sind keine News verfügbar.",
"unavailable": "News sind gerade nicht verfügbar."
},
"languageTrainerSeo": { "languageTrainerSeo": {
"title": "Sprachtrainer fuer den Alltag (Anfaenger)", "title": "Sprachtrainer fuer den Alltag (Anfaenger)",
"introBefore": "YourPart bietet zwei", "introBefore": "YourPart bietet zwei",

View File

@@ -777,6 +777,7 @@
"courseFlowIntensiveStatusAction": "Nächste Wiederholungen prüfen", "courseFlowIntensiveStatusAction": "Nächste Wiederholungen prüfen",
"courseFlowIntensiveStatusTitle": "Nächste Vokabelwiederholungen", "courseFlowIntensiveStatusTitle": "Nächste Vokabelwiederholungen",
"courseFlowIntensiveStatusAllDone": "Für abgeschlossene Lektionen sind derzeit keine weiteren Vokabelwiederholungen geplant.", "courseFlowIntensiveStatusAllDone": "Für abgeschlossene Lektionen sind derzeit keine weiteren Vokabelwiederholungen geplant.",
"courseFlowIntensiveStatusDue": "Heute noch: {remaining} von {quota} eingeplant · insgesamt fällig: {total}",
"courseFlowIntensiveStatusProgress": "{count} Begriffe", "courseFlowIntensiveStatusProgress": "{count} Begriffe",
"courseFlowIntensiveStatusNoDate": "Die Termine richten sich nach deinen richtigen Antworten: 1, 3, 7, 14, 30, 60 und 120 Tage. Schwer markierte Begriffe werden getrennt im Bereich „Schwere Begriffe“ geübt.", "courseFlowIntensiveStatusNoDate": "Die Termine richten sich nach deinen richtigen Antworten: 1, 3, 7, 14, 30, 60 und 120 Tage. Schwer markierte Begriffe werden getrennt im Bereich „Schwere Begriffe“ geübt.",
"courseFlowPracticeTitle": "Freie Vertiefung", "courseFlowPracticeTitle": "Freie Vertiefung",
@@ -875,9 +876,9 @@
"reviewTimeNow": "jetzt", "reviewTimeNow": "jetzt",
"reviewTimeTomorrow": "morgen", "reviewTimeTomorrow": "morgen",
"reviewTimeInDays": "in {count} Tagen", "reviewTimeInDays": "in {count} Tagen",
"srsDueStat": "Tageswiederholung: {scheduled} Begriffe", "srsDueStat": "Tageswiederholung: {scheduled} von {total} Begriffen",
"srsEyebrow": "Langzeitgedächtnis", "srsEyebrow": "Langzeitgedächtnis",
"srsTitle": "Heute: {scheduled} fällige Begriffe", "srsTitle": "Heute: {scheduled} von {total} fälligen Begriffen",
"srsIntro": "Diese Wiederholung kommt aus dem SRS-Plan einzelner Begriffe. Pro Tag werden höchstens 50 Begriffe eingeplant; weitere fällige Begriffe bleiben für die nächste Tageswiederholung erhalten.", "srsIntro": "Diese Wiederholung kommt aus dem SRS-Plan einzelner Begriffe. Pro Tag werden höchstens 50 Begriffe eingeplant; weitere fällige Begriffe bleiben für die nächste Tageswiederholung erhalten.",
"srsStart": "Tageswiederholung starten", "srsStart": "Tageswiederholung starten",
"courseTodayPlanIntroSrs": "Didaktische Reihenfolge: Zuerst die fällige SRS-Tageswiederholung einzelner Begriffe. Danach kommen Kurz-Wiederholungen, Blockfortschritt und ggf. intensive Wiederholung. So wird altes Material stabilisiert, bevor neues Material dazukommt." "courseTodayPlanIntroSrs": "Didaktische Reihenfolge: Zuerst die fällige SRS-Tageswiederholung einzelner Begriffe. Danach kommen Kurz-Wiederholungen, Blockfortschritt und ggf. intensive Wiederholung. So wird altes Material stabilisiert, bevor neues Material dazukommt."

View File

@@ -80,6 +80,13 @@
"title": "Get started", "title": "Get started",
"text": "You can already use, test and give feedback. Register via “{register}” or start the random chat." "text": "You can already use, test and give feedback. Register via “{register}” or start the random chat."
}, },
"news": {
"kicker": "Latest",
"title": "News",
"loading": "Loading news …",
"empty": "There is no news available at the moment.",
"unavailable": "News are currently unavailable."
},
"languageTrainerSeo": { "languageTrainerSeo": {
"title": "Language trainers for everyday use (beginners)", "title": "Language trainers for everyday use (beginners)",
"introBefore": "YourPart offers two", "introBefore": "YourPart offers two",

View File

@@ -777,6 +777,7 @@
"courseFlowIntensiveStatusAction": "Check upcoming reviews", "courseFlowIntensiveStatusAction": "Check upcoming reviews",
"courseFlowIntensiveStatusTitle": "Upcoming vocabulary reviews", "courseFlowIntensiveStatusTitle": "Upcoming vocabulary reviews",
"courseFlowIntensiveStatusAllDone": "No further vocabulary reviews are currently scheduled for completed lessons.", "courseFlowIntensiveStatusAllDone": "No further vocabulary reviews are currently scheduled for completed lessons.",
"courseFlowIntensiveStatusDue": "Remaining today: {remaining} of {quota} planned · total due: {total}",
"courseFlowIntensiveStatusProgress": "{count} terms", "courseFlowIntensiveStatusProgress": "{count} terms",
"courseFlowIntensiveStatusNoDate": "Dates are based on correct answers: 1, 3, 7, 14, 30, 60 and 120 days. Terms marked hard are practised separately in the hard-terms area.", "courseFlowIntensiveStatusNoDate": "Dates are based on correct answers: 1, 3, 7, 14, 30, 60 and 120 days. Terms marked hard are practised separately in the hard-terms area.",
"courseFlowPracticeTitle": "Free practice", "courseFlowPracticeTitle": "Free practice",
@@ -875,9 +876,9 @@
"reviewTimeNow": "now", "reviewTimeNow": "now",
"reviewTimeTomorrow": "tomorrow", "reviewTimeTomorrow": "tomorrow",
"reviewTimeInDays": "in {count} days", "reviewTimeInDays": "in {count} days",
"srsDueStat": "Daily review: {scheduled} terms", "srsDueStat": "Daily review: {scheduled} of {total} terms",
"srsEyebrow": "Long-term memory", "srsEyebrow": "Long-term memory",
"srsTitle": "Today: {scheduled} due terms", "srsTitle": "Today: {scheduled} of {total} due terms",
"srsIntro": "This review comes from the SRS schedule of individual terms. At most 50 terms are planned per day; any additional due terms remain for the next daily review.", "srsIntro": "This review comes from the SRS schedule of individual terms. At most 50 terms are planned per day; any additional due terms remain for the next daily review.",
"srsStart": "Start daily review", "srsStart": "Start daily review",
"courseTodayPlanIntroSrs": "Pedagogical order: start with the due SRS daily review for individual terms. Then quick reviews, block progress, and intensive review if needed. This stabilizes old material before new material is added." "courseTodayPlanIntroSrs": "Pedagogical order: start with the due SRS daily review for individual terms. Then quick reviews, block progress, and intensive review if needed. This stabilizes old material before new material is added."

View File

@@ -80,6 +80,13 @@
"title": "Participa", "title": "Participa",
"text": "Ya puedes usar la plataforma, probarla y darnos tu opinión. Regístrate mediante “{register}” o inicia el chat aleatorio sin compromiso." "text": "Ya puedes usar la plataforma, probarla y darnos tu opinión. Regístrate mediante “{register}” o inicia el chat aleatorio sin compromiso."
}, },
"news": {
"kicker": "Actualidad",
"title": "Noticias",
"loading": "Cargando noticias …",
"empty": "No hay noticias disponibles en este momento.",
"unavailable": "Las noticias no están disponibles en este momento."
},
"languageTrainerSeo": { "languageTrainerSeo": {
"title": "Entrenadores de idiomas para el dia a dia (principiantes)", "title": "Entrenadores de idiomas para el dia a dia (principiantes)",
"introBefore": "YourPart ofrece dos", "introBefore": "YourPart ofrece dos",

View File

@@ -756,6 +756,7 @@
"courseFlowIntensiveStatusAction": "Consultar próximos repasos", "courseFlowIntensiveStatusAction": "Consultar próximos repasos",
"courseFlowIntensiveStatusTitle": "Próximos repasos de vocabulario", "courseFlowIntensiveStatusTitle": "Próximos repasos de vocabulario",
"courseFlowIntensiveStatusAllDone": "Actualmente no hay más repasos de vocabulario programados para las lecciones completadas.", "courseFlowIntensiveStatusAllDone": "Actualmente no hay más repasos de vocabulario programados para las lecciones completadas.",
"courseFlowIntensiveStatusDue": "Pendientes hoy: {remaining} de {quota} planificados · total pendientes: {total}",
"courseFlowIntensiveStatusProgress": "{count} términos", "courseFlowIntensiveStatusProgress": "{count} términos",
"courseFlowIntensiveStatusNoDate": "Las fechas se basan en tus respuestas correctas: 1, 3, 7, 14, 30, 60 y 120 días. Los términos marcados como difíciles se practican por separado en el área de términos difíciles.", "courseFlowIntensiveStatusNoDate": "Las fechas se basan en tus respuestas correctas: 1, 3, 7, 14, 30, 60 y 120 días. Los términos marcados como difíciles se practican por separado en el área de términos difíciles.",
"courseFlowPracticeTitle": "Práctica libre", "courseFlowPracticeTitle": "Práctica libre",

View File

@@ -80,6 +80,13 @@
"title": "Se joindre à", "title": "Se joindre à",
"text": "Vous pouvez déjà utiliser la plateforme, la tester et donner votre avis. Inscrivez-vous via « {register} » ou démarrez le chat aléatoire sans engagement." "text": "Vous pouvez déjà utiliser la plateforme, la tester et donner votre avis. Inscrivez-vous via « {register} » ou démarrez le chat aléatoire sans engagement."
}, },
"news": {
"kicker": "Actualités",
"title": "Nouvelles",
"loading": "Chargement des nouvelles …",
"empty": "Aucune nouvelle nest disponible pour le moment.",
"unavailable": "Les nouvelles ne sont pas disponibles actuellement."
},
"languageTrainerSeo": { "languageTrainerSeo": {
"title": "Formations langues pour le quotidien (debutants)", "title": "Formations langues pour le quotidien (debutants)",
"introBefore": "YourPart propose deux", "introBefore": "YourPart propose deux",

View File

@@ -314,6 +314,7 @@
<th>{{ $t('falukant.family.children.otherParent') }}</th> <th>{{ $t('falukant.family.children.otherParent') }}</th>
<th>{{ $t('falukant.family.children.age') }}</th> <th>{{ $t('falukant.family.children.age') }}</th>
<th>{{ $t('falukant.family.children.heir') }}</th> <th>{{ $t('falukant.family.children.heir') }}</th>
<th>{{ $t('falukant.family.children.matchmaking') }}</th>
<th>{{ $t('falukant.family.children.actions') }}</th> <th>{{ $t('falukant.family.children.actions') }}</th>
</tr> </tr>
</thead> </thead>
@@ -365,6 +366,38 @@
{{ $t('falukant.family.children.setAsHeir') }} {{ $t('falukant.family.children.setAsHeir') }}
</button> </button>
</td> </td>
<td class="child-matchmaking-cell">
<template v-if="child.relationship">
<strong>{{ child.relationship.partnerName }}</strong>
<span>{{ $t('falukant.family.children.relationshipStatus.' + child.relationship.status) }}</span>
<button
v-if="child.relationship.status === 'wooing'"
type="button"
@click="advanceChildWooing(child)"
>
{{ $t('falukant.family.children.completeWooing') }}
</button>
<span v-else-if="child.relationship.status === 'engaged'" class="child-matchmaking-hint">
{{ $t('falukant.family.children.weddingHint') }}
</span>
</template>
<template v-else-if="child.hasName && child.possiblePartners?.length">
<select v-model="selectedChildProposalIds[child.childCharacterId]">
<option :value="null">{{ $t('falukant.family.children.selectPartner') }}</option>
<option v-for="proposal in child.possiblePartners" :key="proposal.id" :value="proposal.proposedCharacterId">
{{ proposal.proposedCharacterName }} ({{ proposal.proposedCharacterAge }})
</option>
</select>
<button
type="button"
:disabled="!selectedChildProposalIds[child.childCharacterId]"
@click="acceptChildProposal(child)"
>
{{ $t('falukant.family.children.arrange') }}
</button>
</template>
<span v-else-if="child.hasName">{{ $t('falukant.family.children.noPartnerProposal') }}</span>
</td>
<td> <td>
<button @click="showChildDetails(child)"> <button @click="showChildDetails(child)">
{{ $t('falukant.family.children.detailButton') }} {{ $t('falukant.family.children.detailButton') }}
@@ -670,6 +703,7 @@ export default {
}, },
pregnancy: null, pregnancy: null,
selectedChild: null, selectedChild: null,
selectedChildProposalIds: {},
pendingFamilyRefresh: null, pendingFamilyRefresh: null,
familyTab: 'partner', familyTab: 'partner',
visualSettings: { ...FALUKANT_VISUAL_DEFAULTS }, visualSettings: { ...FALUKANT_VISUAL_DEFAULTS },
@@ -854,6 +888,9 @@ export default {
const response = await apiClient.get('/api/falukant/family'); const response = await apiClient.get('/api/falukant/family');
this.relationships = response.data.relationships; this.relationships = response.data.relationships;
this.children = response.data.children; this.children = response.data.children;
this.selectedChildProposalIds = Object.fromEntries(
this.children.map((child) => [child.childCharacterId, this.selectedChildProposalIds[child.childCharacterId] || null])
);
this.lovers = response.data.lovers; this.lovers = response.data.lovers;
this.politicalFreeLoverSlots = Number(response.data.politicalFreeLoverSlots) || 0; this.politicalFreeLoverSlots = Number(response.data.politicalFreeLoverSlots) || 0;
this.possibleLovers = response.data.possibleLovers || []; this.possibleLovers = response.data.possibleLovers || [];
@@ -978,6 +1015,30 @@ export default {
} }
}, },
async acceptChildProposal(child) {
const proposedCharacterId = this.selectedChildProposalIds[child.childCharacterId];
if (!proposedCharacterId) return;
try {
await apiClient.post(`/api/falukant/family/children/${child.childCharacterId}/accept-marriage-proposal`, { proposedCharacterId });
await this.loadFamilyData();
showSuccess(this, this.$t('falukant.family.children.arrangeSuccess'));
} catch (error) {
console.error('Error arranging child marriage:', error);
showError(this, error?.response?.data?.message || this.$t('falukant.family.children.arrangeError'));
}
},
async advanceChildWooing(child) {
try {
await apiClient.post(`/api/falukant/family/children/${child.childCharacterId}/advance-wooing`);
await this.loadFamilyData();
showSuccess(this, this.$t('falukant.family.children.engagedSuccess'));
} catch (error) {
console.error('Error advancing child wooing:', error);
showError(this, error?.response?.data?.message || this.$t('falukant.family.children.arrangeError'));
}
},
async setLoverMaintenance(lover, maintenanceLevel) { async setLoverMaintenance(lover, maintenanceLevel) {
try { try {
await apiClient.post(`/api/falukant/family/lover/${lover.relationshipId}/maintenance`, { await apiClient.post(`/api/falukant/family/lover/${lover.relationshipId}/maintenance`, {

View File

@@ -76,6 +76,15 @@
</label> </label>
<div v-if="newPartyTypeId" class="party-options"> <div v-if="newPartyTypeId" class="party-options">
<label v-if="selectedPartyType?.forMarriage">
Hochzeitspaar:
<select v-model.number="relationshipId">
<option :value="null">Bitte Verlobte auswählen</option>
<option v-for="candidate in weddingCandidates" :key="candidate.relationshipId" :value="candidate.relationshipId">
{{ candidate.label }}
</option>
</select>
</label>
<label> <label>
{{ $t('falukant.reputation.party.music.label') }}: {{ $t('falukant.reputation.party.music.label') }}:
<select v-model.number="musicId"> <select v-model.number="musicId">
@@ -124,7 +133,7 @@
</div> </div>
<div> <div>
<button type="button" @click="orderParty()"> <button type="button" @click="orderParty()" :disabled="isOrderingParty">
{{ $t('falukant.reputation.party.order') }} {{ $t('falukant.reputation.party.order') }}
</button> </button>
</div> </div>
@@ -214,6 +223,9 @@ export default {
nobilityTitles: [], nobilityTitles: [],
selectedNobilityIds: [], selectedNobilityIds: [],
servantRatio: 50, servantRatio: 50,
relationshipId: null,
isOrderingParty: false,
weddingCandidates: [],
inProgressParties: [], inProgressParties: [],
completedParties: [], completedParties: [],
reputation: null, reputation: null,
@@ -235,6 +247,7 @@ export default {
this.partyTypes = data.partyTypes; this.partyTypes = data.partyTypes;
this.musicTypes = data.musicTypes; this.musicTypes = data.musicTypes;
this.banquetteTypes = data.banquetteTypes; this.banquetteTypes = data.banquetteTypes;
this.weddingCandidates = data.weddingCandidates || [];
this.musicId = this.musicTypes[0]?.id; this.musicId = this.musicTypes[0]?.id;
this.banquetteId = this.banquetteTypes[0]?.id; this.banquetteId = this.banquetteTypes[0]?.id;
}, },
@@ -305,14 +318,23 @@ export default {
this.nobilityTitles = await apiClient.get('/api/falukant/nobility/titels').then(r => r.data) this.nobilityTitles = await apiClient.get('/api/falukant/nobility/titels').then(r => r.data)
}, },
async orderParty() { async orderParty() {
await apiClient.post('/api/falukant/party', { if (this.isOrderingParty) return;
partyTypeId: this.newPartyTypeId, this.isOrderingParty = true;
musicId: this.musicId, try {
banquetteId: this.banquetteId, await apiClient.post('/api/falukant/party', {
nobilityIds: this.selectedNobilityIds.map(n => n.id ?? n), partyTypeId: this.newPartyTypeId,
servantRatio: this.servantRatio musicId: this.musicId,
}); banquetteId: this.banquetteId,
this.toggleNewPartyView(); nobilityIds: this.selectedNobilityIds.map(n => n.id ?? n),
servantRatio: this.servantRatio,
relationshipId: this.relationshipId
});
await this.loadParties();
this.relationshipId = null;
this.toggleNewPartyView();
} finally {
this.isOrderingParty = false;
}
}, },
getPartyDate(createdAt) { getPartyDate(createdAt) {
// Feste finden 1 Tag nach der Bestellung statt // Feste finden 1 Tag nach der Bestellung statt
@@ -343,6 +365,10 @@ export default {
maximumFractionDigits: 2 maximumFractionDigits: 2
}); });
} }
,
selectedPartyType() {
return this.partyTypes.find((type) => type.id === this.newPartyTypeId) || null;
}
}, },
async mounted() { async mounted() {
const tabFromQuery = this.$route?.query?.tab; const tabFromQuery = this.$route?.query?.tab;

View File

@@ -1,14 +1,10 @@
<template> <template>
<div class="home-logged-in"> <div class="home-logged-in">
<section class="dashboard-hero surface-card"> <header class="dashboard-hero">
<div class="dashboard-hero__copy"> <div class="dashboard-hero__copy">
<span class="dashboard-kicker">{{ $t('home.dashboard.kicker') }}</span>
<h1>{{ $t('home.dashboard.title') }}</h1> <h1>{{ $t('home.dashboard.title') }}</h1>
<p class="dashboard-subtitle">
{{ $t('home.dashboard.subtitle') }}
</p>
</div> </div>
<div class="dashboard-toolbar surface-card"> <div class="dashboard-toolbar">
<button <button
v-if="!editMode" v-if="!editMode"
type="button" type="button"
@@ -47,25 +43,7 @@
</button> </button>
</template> </template>
</div> </div>
</section> </header>
<section class="dashboard-overview">
<article class="overview-card surface-card">
<span class="overview-card__label">{{ $t('home.dashboard.overview.activeWidgetsLabel') }}</span>
<strong>{{ widgets.length }}</strong>
<p>{{ $t('home.dashboard.overview.activeWidgetsText') }}</p>
</article>
<article class="overview-card surface-card">
<span class="overview-card__label">{{ $t('home.dashboard.overview.availableModulesLabel') }}</span>
<strong>{{ widgetTypeOptions.length }}</strong>
<p>{{ $t('home.dashboard.overview.availableModulesText') }}</p>
</article>
<article class="overview-card surface-card">
<span class="overview-card__label">{{ $t('home.dashboard.overview.editModeLabel') }}</span>
<strong>{{ editMode ? $t('home.dashboard.overview.editModeActive') : $t('home.dashboard.overview.editModeInactive') }}</strong>
<p>{{ editMode ? $t('home.dashboard.overview.editModeActiveText') : $t('home.dashboard.overview.editModeInactiveText') }}</p>
</article>
</section>
<div <div
v-if="loadError" v-if="loadError"
@@ -83,12 +61,6 @@
v-else v-else
class="dashboard-shell" class="dashboard-shell"
> >
<div class="dashboard-shell__header">
<div>
<h2>{{ $t('home.dashboard.sectionTitle') }}</h2>
<p>{{ $t('home.dashboard.sectionIntro') }}</p>
</div>
</div>
<div <div
ref="dashboardGridRef" ref="dashboardGridRef"
class="dashboard-grid" class="dashboard-grid"
@@ -382,85 +354,29 @@ export default {
.dashboard-hero { .dashboard-hero {
display: flex; display: flex;
align-items: stretch; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 20px; gap: 20px;
padding: 26px; min-height: 40px;
margin-bottom: 18px; padding: 4px 0 10px;
background: var(--color-surface); margin-bottom: 12px;
border-left: 3px solid var(--color-secondary);
} }
.dashboard-hero__copy { .dashboard-hero__copy {
max-width: 640px; min-width: 0;
}
.dashboard-kicker {
display: inline-block;
margin-bottom: 10px;
padding: 4px 10px;
border-radius: 2px;
background: var(--color-secondary-soft);
color: #28643d;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
} }
.dashboard-hero h1 { .dashboard-hero h1 {
margin: 0 0 8px;
}
.dashboard-subtitle {
color: var(--color-text-secondary);
margin: 0; margin: 0;
max-width: 58ch;
}
.dashboard-overview {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
margin-bottom: 18px;
}
.overview-card {
padding: 18px 20px;
}
.overview-card__label {
display: inline-block;
margin-bottom: 12px;
color: var(--color-text-muted);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.overview-card strong {
display: block;
margin-bottom: 8px;
font-size: 1.9rem;
line-height: 1;
color: var(--color-text-primary);
}
.overview-card p {
margin: 0;
color: var(--color-text-secondary);
} }
.dashboard-toolbar { .dashboard-toolbar {
display: flex; display: flex;
align-items: center; align-items: center;
align-self: flex-start;
flex-wrap: wrap; flex-wrap: wrap;
gap: 10px; gap: 10px;
padding: 14px; padding: 0;
min-width: 300px; min-width: 0;
background: rgba(255, 255, 255, 0.72);
} }
.btn-edit, .btn-edit,
@@ -508,28 +424,8 @@ export default {
} }
.dashboard-shell { .dashboard-shell {
padding: 20px; /* Widgets are the content; do not wrap them in an additional overview card. */
border-radius: var(--radius-lg); padding: 0;
border: 1px solid var(--color-border);
background: var(--color-surface);
box-shadow: var(--shadow-soft);
}
.dashboard-shell__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.dashboard-shell__header h2 {
margin: 0 0 4px;
font-size: 1.4rem;
}
.dashboard-shell__header p {
margin: 0;
color: var(--color-text-secondary);
} }
.dashboard-grid { .dashboard-grid {
@@ -614,8 +510,8 @@ export default {
} }
.dashboard-hero { .dashboard-hero {
align-items: flex-start;
flex-direction: column; flex-direction: column;
padding: 20px;
} }
.dashboard-toolbar { .dashboard-toolbar {
@@ -623,14 +519,6 @@ export default {
min-width: 0; min-width: 0;
} }
.dashboard-overview {
grid-template-columns: 1fr;
}
.dashboard-shell {
padding: 16px;
}
.dashboard-grid { .dashboard-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }

View File

@@ -101,6 +101,28 @@
<PasswordResetDialog ref="passwordResetDialog" /> <PasswordResetDialog ref="passwordResetDialog" />
</div> </div>
<section class="public-news surface-card" aria-labelledby="public-news-title">
<div class="public-news__header">
<div>
<span class="panel-kicker">{{ $t('home.nologin.news.kicker') }}</span>
<h2 id="public-news-title">{{ $t('home.nologin.news.title') }}</h2>
</div>
<span v-if="newsLoading" class="public-news__status">{{ $t('home.nologin.news.loading') }}</span>
</div>
<p v-if="newsError" class="public-news__status">{{ $t('home.nologin.news.unavailable') }}</p>
<div v-else-if="news.length" class="public-news__grid">
<article v-for="article in news" :key="article.article_id || article.link || article.title" class="public-news__article">
<span v-if="article.pubDate" class="public-news__date">{{ formatNewsDate(article.pubDate) }}</span>
<a v-if="article.link" :href="article.link" target="_blank" rel="noopener noreferrer" class="public-news__title">
{{ article.title }}
</a>
<h3 v-else class="public-news__title">{{ article.title }}</h3>
<p v-if="article.description" class="public-news__description">{{ summarizeNews(article.description) }}</p>
</article>
</div>
<p v-else-if="!newsLoading" class="public-news__status">{{ $t('home.nologin.news.empty') }}</p>
</section>
<section class="seo-content surface-card" aria-label="Sprachtrainer"> <section class="seo-content surface-card" aria-label="Sprachtrainer">
<h2>{{ $t('home.nologin.languageTrainerSeo.title') }}</h2> <h2>{{ $t('home.nologin.languageTrainerSeo.title') }}</h2>
<p> <p>
@@ -160,6 +182,9 @@ export default {
oauthProviders: [], oauthProviders: [],
oauthLoading: false, oauthLoading: false,
isStoryCollapsed: true, isStoryCollapsed: true,
news: [],
newsLoading: true,
newsError: false,
}; };
}, },
components: { components: {
@@ -190,6 +215,36 @@ export default {
this.oauthProviders = []; this.oauthProviders = [];
} }
}, },
getNewsLanguage() {
const language = String(this.$i18n?.locale || 'de').toLowerCase();
return ['de', 'en', 'es', 'fr'].includes(language) ? language : 'en';
},
async loadNews() {
this.newsLoading = true;
this.newsError = false;
try {
const { data } = await apiClient.get('/api/news', {
params: { language: this.getNewsLanguage(), category: 'top', count: 3 }
});
this.news = Array.isArray(data?.results) ? data.results.filter((article) => article?.title) : [];
} catch (error) {
this.news = [];
this.newsError = true;
} finally {
this.newsLoading = false;
}
},
formatNewsDate(dateStr) {
const date = new Date(dateStr);
if (Number.isNaN(date.getTime())) return '';
return date.toLocaleDateString(this.$i18n?.locale || 'de-DE', {
day: 'numeric', month: 'short', year: 'numeric'
});
},
summarizeNews(description) {
const text = String(description || '').replace(/\s+/g, ' ').trim();
return text.length > 210 ? `${text.slice(0, 207).trimEnd()}` : text;
},
startOAuthLogin(providerSlug) { startOAuthLogin(providerSlug) {
if (this.oauthLoading) { if (this.oauthLoading) {
return; return;
@@ -229,7 +284,7 @@ export default {
} }
}, },
async created() { async created() {
await this.loadOAuthProviders(); await Promise.all([this.loadOAuthProviders(), this.loadNews()]);
}, },
mounted() { mounted() {
this.$nextTick(() => { this.$nextTick(() => {
@@ -554,6 +609,71 @@ export default {
border-radius: 4px; border-radius: 4px;
} }
.public-news {
width: min(100%, 1120px);
margin: 24px auto 0;
padding: 1.25rem;
}
.public-news__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1rem;
}
.public-news__header .panel-kicker {
margin-bottom: 0.45rem;
}
.public-news__header h2 {
margin: 0;
}
.public-news__grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.9rem;
}
.public-news__article {
display: flex;
flex-direction: column;
gap: 0.45rem;
padding: 1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
background: rgba(255, 255, 255, 0.7);
}
.public-news__date,
.public-news__status {
color: var(--color-text-secondary);
font-size: 0.85rem;
}
.public-news__title {
margin: 0;
color: var(--color-text-primary);
font-size: 1rem;
line-height: 1.35;
font-weight: 700;
text-decoration: none;
}
a.public-news__title:hover {
color: var(--color-primary);
text-decoration: underline;
}
.public-news__description {
margin: 0;
color: var(--color-text-secondary);
font-size: 0.9rem;
line-height: 1.45;
}
.seo-content h1 { .seo-content h1 {
font-size: 28px; font-size: 28px;
margin: 0 0 8px 0; margin: 0 0 8px 0;
@@ -626,7 +746,8 @@ export default {
.story-columns, .story-columns,
.access-split, .access-split,
.login-fields, .login-fields,
.oauth-provider-list { .oauth-provider-list,
.public-news__grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }

View File

@@ -317,8 +317,11 @@
<section class="dialog intensive-status-dialog" role="dialog" aria-modal="true" @click.stop> <section class="dialog intensive-status-dialog" role="dialog" aria-modal="true" @click.stop>
<h3>{{ $t('socialnetwork.vocab.courses.courseFlowIntensiveStatusTitle') }}</h3> <h3>{{ $t('socialnetwork.vocab.courses.courseFlowIntensiveStatusTitle') }}</h3>
<p class="intensive-status-dialog__hint">{{ $t('socialnetwork.vocab.courses.courseFlowIntensiveStatusNoDate') }}</p> <p class="intensive-status-dialog__hint">{{ $t('socialnetwork.vocab.courses.courseFlowIntensiveStatusNoDate') }}</p>
<p v-if="srsUpcoming.length === 0">{{ $t('socialnetwork.vocab.courses.courseFlowIntensiveStatusAllDone') }}</p> <p v-if="srsDueCount === 0 && srsUpcoming.length === 0">{{ $t('socialnetwork.vocab.courses.courseFlowIntensiveStatusAllDone') }}</p>
<ul v-else class="intensive-status-dialog__list"> <ul v-else class="intensive-status-dialog__list">
<li v-if="srsDueCount > 0" class="intensive-status-dialog__item--due">
<span>{{ $t('socialnetwork.vocab.courses.courseFlowIntensiveStatusDue', { remaining: srsDailyCount, quota: srsDailyQuota, total: srsDueCount }) }}</span>
</li>
<li v-for="entry in srsUpcoming" :key="entry.date"> <li v-for="entry in srsUpcoming" :key="entry.date">
<span>{{ formatSrsDate(entry.date) }}</span> <span>{{ formatSrsDate(entry.date) }}</span>
<strong>{{ $t('socialnetwork.vocab.courses.courseFlowIntensiveStatusProgress', { count: entry.count }) }}</strong> <strong>{{ $t('socialnetwork.vocab.courses.courseFlowIntensiveStatusProgress', { count: entry.count }) }}</strong>
@@ -393,6 +396,7 @@ export default {
srsDueTotal: 0, srsDueTotal: 0,
srsDailyLimit: 50, srsDailyLimit: 50,
srsTodayRemaining: null, srsTodayRemaining: null,
srsTodayQuota: null,
srsUpcoming: [], srsUpcoming: [],
srsLoading: false, srsLoading: false,
showIntensiveStatusDialog: false, showIntensiveStatusDialog: false,
@@ -445,11 +449,19 @@ export default {
return Array.isArray(this.srsDueItems) ? this.srsDueItems.length : 0; return Array.isArray(this.srsDueItems) ? this.srsDueItems.length : 0;
}, },
srsDailyCount() { srsDailyCount() {
// A valid current-day session represents the fixed daily batch. Without
// one, the server's due count determines the first batch (max. 50).
if (Number.isFinite(Number(this.srsTodayRemaining))) { if (Number.isFinite(Number(this.srsTodayRemaining))) {
return Math.max(0, Number(this.srsTodayRemaining)); return Math.max(0, Number(this.srsTodayRemaining));
} }
return Math.min(this.srsDueCount, this.srsDailyLimit); return Math.min(this.srsDueCount, this.srsDailyLimit);
}, },
srsDailyQuota() {
if (Number.isFinite(Number(this.srsTodayQuota))) {
return Math.max(0, Number(this.srsTodayQuota));
}
return Math.min(this.srsDueCount, this.srsDailyLimit);
},
hardVocabCount() { hardVocabCount() {
return Array.isArray(this.hardVocabList) ? this.hardVocabList.length : 0; return Array.isArray(this.hardVocabList) ? this.hardVocabList.length : 0;
}, },
@@ -584,13 +596,18 @@ export default {
&& session.dateKey === this.getLocalDateKey(); && session.dateKey === this.getLocalDateKey();
if (!isCurrentSession) { if (!isCurrentSession) {
this.srsTodayRemaining = null; this.srsTodayRemaining = null;
this.srsTodayQuota = null;
return; return;
} }
const total = Math.max(0, Number(session.initialTotalDue) || 0); const total = Math.max(0, Number(session.initialTotalDue) || 0);
const done = new Set(Array.isArray(session.doneIds) ? session.doneIds : []).size; const initialIds = new Set(Array.isArray(session.initialDueIds) ? session.initialDueIds : []);
const done = new Set((Array.isArray(session.doneIds) ? session.doneIds : [])
.filter((id) => initialIds.has(id))).size;
this.srsTodayRemaining = Math.max(0, total - done); this.srsTodayRemaining = Math.max(0, total - done);
this.srsTodayQuota = total;
} catch (_) { } catch (_) {
this.srsTodayRemaining = null; this.srsTodayRemaining = null;
this.srsTodayQuota = null;
} }
}, },
async refreshHardVocabList() { async refreshHardVocabList() {

View File

@@ -305,9 +305,10 @@
</div> </div>
</div> </div>
<div v-if="currentVocabQuestion" class="vocab-question"> <div v-if="currentVocabQuestion" class="vocab-question">
<div class="vocab-prompt"> <div class="vocab-prompt">
<div class="vocab-direction">{{ vocabTrainerDirection === 'L2R' ? $t('socialnetwork.vocab.courses.translateTo') : $t('socialnetwork.vocab.courses.translateFrom') }}</div> <div class="vocab-direction">{{ currentVocabQuestion.directionLabel || (vocabTrainerDirection === 'L2R' ? $t('socialnetwork.vocab.courses.translateTo') : $t('socialnetwork.vocab.courses.translateFrom')) }}</div>
<div class="vocab-word">{{ currentVocabQuestion.prompt }}</div> <div class="vocab-word">{{ currentVocabQuestion.prompt }}</div>
<p v-if="currentVocabQuestion.grammarHint" class="vocab-grammar-hint">{{ currentVocabQuestion.grammarHint }}</p>
</div> </div>
<div v-if="vocabTrainerAnswered" class="vocab-feedback" :class="{ correct: vocabTrainerLastCorrect, wrong: !vocabTrainerLastCorrect }"> <div v-if="vocabTrainerAnswered" class="vocab-feedback" :class="{ correct: vocabTrainerLastCorrect, wrong: !vocabTrainerLastCorrect }">
<div v-if="vocabTrainerLastCorrect">{{ $t('socialnetwork.vocab.courses.correct') }}!</div> <div v-if="vocabTrainerLastCorrect">{{ $t('socialnetwork.vocab.courses.correct') }}!</div>
@@ -1464,14 +1465,11 @@ export default {
exerciseTargetScore() { exerciseTargetScore() {
return Number(this.lesson?.targetScorePercent) || 80; return Number(this.lesson?.targetScorePercent) || 80;
}, },
/** Kapitel-Prüfung: eine Frage pro Ansicht (Essen & Trinken: deterministisch gemischt). */ /** Kapitel-Prüfung: eine Frage pro Ansicht, immer unabhängig von der Eingabereihenfolge gemischt. */
scrambledChapterExamExercises() { scrambledChapterExamExercises() {
const raw = this.effectiveExercises; const raw = this.effectiveExercises;
if (!raw.length) return []; if (!raw.length) return [];
if ((this.lesson?.title || '').trim() === 'Essen & Trinken') { return this._deterministicShuffle(raw.slice(), Number(this.lessonId) || 1);
return this._deterministicShuffle(raw.slice(), Number(this.lessonId) || 1);
}
return raw;
}, },
sequentialPanelActive() { sequentialPanelActive() {
return (this.scrambledChapterExamExercises?.length || 0) > 1; return (this.scrambledChapterExamExercises?.length || 0) > 1;
@@ -2908,46 +2906,6 @@ export default {
} }
await this.$nextTick(); await this.$nextTick();
let exercises = this.effectiveExercises; let exercises = this.effectiveExercises;
// Wenn das Backend eine chapterLexemeTraining-Liste liefert, wandle sie
// in temporäre gap_fill-Übungen um und mische sie in die grammarExercises
try {
if (this.lesson && Array.isArray(this.lesson.chapterLexemeTraining) && this.lesson.chapterLexemeTraining.length) {
const temps = this.lesson.chapterLexemeTraining.map((item, idx) => {
const id = `cht-${this.lesson.id}-${String(item.id)}`;
// Wenn reference mehrere Wörter enthält, ersetze das erste Wort durch die Lücke
const ref = String(item.reference || '').trim();
let qText = `{gap} (${item.learning})`;
let answers = [ref];
if (ref) {
const parts = ref.split(/\s+/).filter(Boolean);
if (parts.length > 1) {
const tail = parts.slice(1).join(' ');
qText = `{gap} ${tail}`;
answers = [parts[0]];
} else {
// single token reference: show blank plus learning hint
qText = `{gap} (${item.learning})`;
answers = [parts[0]];
}
}
return {
id,
lessonId: this.lesson.id,
exerciseTypeId: 1,
title: `Kapitel-Vokabel: ${item.learning}`,
instruction: this.$t('socialnetwork.vocab.courses.fillTheBlank') || 'Lückentext',
questionData: { type: 'gap_fill', text: qText },
answerData: { type: 'gap_fill', answers }
};
});
this.lesson.grammarExercises = Array.isArray(this.lesson.grammarExercises)
? [...this.lesson.grammarExercises, ...temps]
: temps;
exercises = this.effectiveExercises;
}
} catch (err) {
console.warn('[VocabLessonView] Fehler beim Einfügen von chapterLexemeTraining:', err);
}
if (!exercises || exercises.length === 0) { if (!exercises || exercises.length === 0) {
debugLog('[VocabLessonView] Lade Übungen separat...'); debugLog('[VocabLessonView] Lade Übungen separat...');
await this.loadGrammarExercises(); await this.loadGrammarExercises();
@@ -4187,6 +4145,129 @@ export default {
} }
return arr; return arr;
}, },
isGermanCourse() {
return /^(deutsch|german)$/i.test(String(this.courseLanguageName || '').trim());
},
germanFormSource(vocab) {
if (!this.isGermanCourse()) return '';
return [vocab?.learning, vocab?.reference]
.map((value) => String(value || '').trim())
.find((value) => /^(?:der|die|das)\s+[^\s]+$/i.test(value) || /^[a-zäöüß]+en$/i.test(value)) || '';
},
germanPresentForms(infinitive) {
const verb = String(infinitive || '').trim().toLowerCase();
const irregular = {
gehen: ['gehe', 'gehst', 'geht', 'gehen', 'geht', 'gehen'],
sein: ['bin', 'bist', 'ist', 'sind', 'seid', 'sind'],
haben: ['habe', 'hast', 'hat', 'haben', 'habt', 'haben'],
werden: ['werde', 'wirst', 'wird', 'werden', 'werdet', 'werden'],
sprechen: ['spreche', 'sprichst', 'spricht', 'sprechen', 'sprecht', 'sprechen'],
fahren: ['fahre', 'fährst', 'fährt', 'fahren', 'fahrt', 'fahren'],
lesen: ['lese', 'liest', 'liest', 'lesen', 'lest', 'lesen'],
sehen: ['sehe', 'siehst', 'sieht', 'sehen', 'seht', 'sehen']
};
if (irregular[verb]) return irregular[verb];
// Regular verbs only: this intentionally avoids teaching incorrect
// stem changes for verbs whose form is not known here.
if (!/^[a-zäöüß]+en$/i.test(verb) || /(?:eln|ern)$/i.test(verb)) return null;
const stem = verb.slice(0, -2);
if (!stem) return null;
const needsExtraE = /(?:d|t|chn|ffn|gn|tm)$/i.test(stem);
const du = /(?:s|ß|x|z)$/i.test(stem) ? `${stem}t` : `${stem}${needsExtraE ? 'est' : 'st'}`;
const er = `${stem}${needsExtraE ? 'et' : 't'}`;
return [`${stem}e`, du, er, `${stem}en`, `${stem}${needsExtraE ? 'et' : 't'}`, `${stem}en`];
},
buildGrammarPracticeQuestion(vocab, source) {
const german = this.germanFormSource(vocab);
if (!german || Math.random() >= 0.35) return null;
const articleMatch = german.match(/^(der|die|das)\s+([^\s]+)$/i);
if (articleMatch) {
const article = articleMatch[1].toLowerCase();
const noun = articleMatch[2];
return {
vocab,
prompt: `_____ ${noun}`,
answers: [article],
answer: article,
key: this.getVocabKey(vocab),
source,
kind: 'article',
directionLabel: 'Welcher Artikel passt?',
choiceOptions: ['der', 'die', 'das']
};
}
const forms = this.germanPresentForms(german);
if (!forms) return null;
const pronouns = ['ich', 'du', 'er/sie/es', 'wir', 'ihr', 'sie/Sie'];
const index = Math.floor(Math.random() * pronouns.length);
const answer = forms[index];
return {
vocab,
prompt: `${pronouns[index]} _____ (${german})`,
answers: [answer],
answer,
key: this.getVocabKey(vocab),
source,
kind: 'conjugation',
directionLabel: 'Setze die richtige Verbform ein.',
choiceOptions: [...new Set(forms)].sort(() => Math.random() - 0.5)
};
},
isBisayaCourse() {
return /(?:bisaya|cebuano)/i.test(String(this.courseLanguageName || '').trim());
},
bisayaCliticRule(token) {
const rules = {
ko: 'ko: „ich/mir“ als kurze Personenform, z. B. „Maayo ko.“',
ka: 'ka: „du/dir“ als kurze Personenform, z. B. „Kumusta ka?“',
imong: 'imong: „dein/deine“ direkt vor einem Nomen, z. B. „imong bag“.',
koy: 'koy: zusammengezogene Form von ko + ay; häufig nach naa/aduna: „Naa koy …“',
nako: 'nako: „von mir/für mich“ in Mustern wie „Kinahanglan nako …“',
nimo: 'nimo: „von dir/für dich“ bzw. „du“ in Mustern wie „Pwede nimo ko tabangan?“',
tika: 'tika: „dich“ als angehängte Objektform, z. B. „Tabangan tika.“'
};
return rules[token] || '';
},
lessonPatternTexts() {
const patterns = Array.isArray(this.lessonDidactics?.corePatterns)
? this.lessonDidactics.corePatterns
: [];
const grammarExamples = Array.isArray(this.lessonDidactics?.grammarFocus)
? this.lessonDidactics.grammarFocus.map((entry) => entry?.example)
: [];
return [...patterns, ...grammarExamples]
.map((entry) => typeof entry === 'object' ? entry?.target : entry)
.map((entry) => String(entry || '').trim())
.filter(Boolean);
},
buildBisayaLessonPatternQuestion(vocab, source) {
if (!this.isBisayaCourse() || Math.random() >= 0.35) return null;
const tokens = ['imong', 'nimo', 'nako', 'tika', 'koy', 'ko', 'ka'];
const candidates = [];
this.lessonPatternTexts().forEach((text) => {
tokens.forEach((token) => {
const matcher = new RegExp(`(^|\\s)${token}(?=\\s|[,.?!]|$)`, 'i');
if (!matcher.test(text)) return;
candidates.push({ text, token });
});
});
if (!candidates.length) return null;
const picked = candidates[Math.floor(Math.random() * candidates.length)];
const matcher = new RegExp(`(^|\\s)${picked.token}(?=\\s|[,.?!]|$)`, 'i');
const prompt = picked.text.replace(matcher, (_match, prefix) => `${prefix}_____`);
return {
vocab,
prompt,
answers: [picked.token],
answer: picked.token,
key: this.getVocabKey(vocab),
source,
kind: 'bisaya_clitic',
directionLabel: 'Ergänze die passende Bisaya-Form.',
grammarHint: this.bisayaCliticRule(picked.token),
choiceOptions: this._shuffleArray(['ko', 'ka', 'imong', 'koy', 'nako', 'nimo', 'tika'])
};
},
nextVocabQuestion() { nextVocabQuestion() {
debugLog('[VocabLessonView] nextVocabQuestion aufgerufen'); debugLog('[VocabLessonView] nextVocabQuestion aufgerufen');
this.clearVocabTrainerContinueTimer(); this.clearVocabTrainerContinueTimer();
@@ -4318,6 +4399,25 @@ export default {
this.currentVocabQuestion = null; this.currentVocabQuestion = null;
return; return;
} }
// Grammar belongs in the trainer itself: German learners regularly
// retrieve articles and verb forms alongside translations.
const grammarQuestion = !dueRepeatVocab
? (this.buildBisayaLessonPatternQuestion(vocab, questionSource)
|| this.buildGrammarPracticeQuestion(vocab, questionSource))
: null;
if (grammarQuestion) {
this.currentVocabQuestion = grammarQuestion;
this.vocabTrainerAnswer = '';
this.vocabTrainerSelectedChoice = null;
this.vocabTrainerAnswered = false;
if (this.vocabTrainerMode === 'multiple_choice') {
this.vocabTrainerChoiceOptions = grammarQuestion.choiceOptions;
}
if (this.vocabTrainerMode === 'typing') {
this.$nextTick(() => this.$refs.vocabInput?.focus?.());
}
return;
}
this.vocabTrainerDirection = Math.random() < 0.5 ? 'L2R' : 'R2L'; this.vocabTrainerDirection = Math.random() < 0.5 ? 'L2R' : 'R2L';
const allTrainerVocabs = [...this.trainableLessonVocab, ...this.vocabTrainerMixedPool]; const allTrainerVocabs = [...this.trainableLessonVocab, ...this.vocabTrainerMixedPool];
const direction = this.vocabTrainerDirection; const direction = this.vocabTrainerDirection;
@@ -6082,6 +6182,14 @@ export default {
color: #333; color: #333;
} }
.vocab-grammar-hint {
margin: 10px 0 0;
padding-top: 9px;
border-top: 1px solid #ddd;
color: #4b5563;
font-size: 0.92em;
}
.vocab-answer-area { .vocab-answer-area {
margin-bottom: 15px; margin-bottom: 15px;
} }

View File

@@ -123,8 +123,8 @@ private:
ON fu1.id = c1.user_id ON fu1.id = c1.user_id
LEFT JOIN falukant_data.falukant_user fu2 LEFT JOIN falukant_data.falukant_user fu2
ON fu2.id = c2.user_id ON fu2.id = c2.user_id
WHERE c1.birthdate <= CURRENT_DATE - INTERVAL '16 days' WHERE c1.birthdate <= CURRENT_DATE - INTERVAL '14 days'
AND c2.birthdate <= CURRENT_DATE - INTERVAL '16 days' AND c2.birthdate <= CURRENT_DATE - INTERVAL '14 days'
AND random()*100 < ( AND random()*100 < (
100.0 / 100.0 /
(1 (1

View File

@@ -107,13 +107,8 @@ private:
JOIN falukant_type.party AS pt JOIN falukant_type.party AS pt
ON pt.id = p.party_type_id ON pt.id = p.party_type_id
AND pt.tr = 'wedding' AND pt.tr = 'wedding'
JOIN falukant_data.falukant_user AS fu
ON fu.id = p.falukant_user_id
JOIN falukant_data."character" AS c
ON c.user_id = fu.id
JOIN falukant_data.relationship AS rel2 JOIN falukant_data.relationship AS rel2
ON rel2.character1_id = c.id ON p.relationship_id = rel2.id
OR rel2.character2_id = c.id
JOIN falukant_type.relationship AS rt2 JOIN falukant_type.relationship AS rt2
ON rt2.id = rel2.relationship_type_id ON rt2.id = rel2.relationship_type_id
AND rt2.tr = 'engaged' AND rt2.tr = 'engaged'
@@ -121,19 +116,15 @@ private:
-- Die Feier muss für diese bereits bestehende Verlobung bestellt worden sein. -- Die Feier muss für diese bereits bestehende Verlobung bestellt worden sein.
AND p.created_at >= rel2.created_at AND p.created_at >= rel2.created_at
-- Ein realer Tag entspricht einem Spieljahr: Hochzeit ab 14. -- Ein realer Tag entspricht einem Spieljahr: Hochzeit ab 14.
AND c.birthdate <= CURRENT_DATE - INTERVAL '14 days' AND EXISTS (
AND ( SELECT 1 FROM falukant_data."character" spouse1
(rel2.character1_id = c.id AND EXISTS ( WHERE spouse1.id = rel2.character1_id
SELECT 1 FROM falukant_data."character" partner AND spouse1.birthdate <= CURRENT_DATE - INTERVAL '14 days'
WHERE partner.id = rel2.character2_id )
AND partner.birthdate <= CURRENT_DATE - INTERVAL '14 days' AND EXISTS (
)) SELECT 1 FROM falukant_data."character" spouse2
OR WHERE spouse2.id = rel2.character2_id
(rel2.character2_id = c.id AND EXISTS ( AND spouse2.birthdate <= CURRENT_DATE - INTERVAL '14 days'
SELECT 1 FROM falukant_data."character" partner
WHERE partner.id = rel2.character1_id
AND partner.birthdate <= CURRENT_DATE - INTERVAL '14 days'
))
) )
) )
RETURNING character1_id, character2_id RETURNING character1_id, character2_id

View File

@@ -7,6 +7,7 @@ FRONTEND_DIR="$TARGET_DIR/frontend"
CURRENT_LINK="/opt/yourpart" CURRENT_LINK="/opt/yourpart"
CURRENT_FRONTEND="$CURRENT_LINK/frontend" CURRENT_FRONTEND="$CURRENT_LINK/frontend"
CURRENT_ROOT_ENV="$CURRENT_LINK/.env" CURRENT_ROOT_ENV="$CURRENT_LINK/.env"
MODELS_MANIFEST="$TARGET_DIR/falukant-models.env"
echo "=== YourPart Frontend Update ===" echo "=== YourPart Frontend Update ==="
echo "Ziel: $FRONTEND_DIR" echo "Ziel: $FRONTEND_DIR"
@@ -54,14 +55,36 @@ echo "VITE_CHAT_WS_URL=$VITE_CHAT_WS_URL"
echo "Installiere Dependencies..." echo "Installiere Dependencies..."
npm install npm install
if [ ! -f "$MODELS_MANIFEST" ]; then
echo "ERROR: Falukant model manifest missing: $MODELS_MANIFEST" >&2
exit 1
fi
# Source GLBs are versioned as a Gitea release attachment rather than Git
# objects, keeping routine clones and deployments small.
# shellcheck disable=SC1090
source "$MODELS_MANIFEST"
: "${FALUKANT_MODELS_URL:?Falukant model URL is missing}"
: "${FALUKANT_MODELS_SHA256:?Falukant model checksum is missing}"
MODELS_ARCHIVE="$(mktemp /tmp/falukant-models-XXXXXX.tar.gz)"
trap 'rm -f "$TEMP_ENV" "$MODELS_ARCHIVE"' EXIT
echo "Lade Falukant-3D-Modelle (${FALUKANT_MODELS_URL})..."
curl --fail --location --retry 3 --retry-delay 2 \
--output "$MODELS_ARCHIVE" "$FALUKANT_MODELS_URL"
echo "${FALUKANT_MODELS_SHA256} ${MODELS_ARCHIVE}" | sha256sum --check --status
echo "Entpacke Falukant-3D-Modelle..."
rm -rf "$FRONTEND_DIR/models-src/3d/falukant"
tar -xzf "$MODELS_ARCHIVE" -C "$TARGET_DIR"
echo "Optimiere Falukant-3D-Modelle..." echo "Optimiere Falukant-3D-Modelle..."
npm run optimize-models npm run optimize-models
echo "Baue Frontend..." echo "Baue Frontend..."
npm run build npm run build
rm -f "$TEMP_ENV"
if [ -f "$FRONTEND_DIR/.env" ]; then if [ -f "$FRONTEND_DIR/.env" ]; then
echo "✓ Bestehende .env-Datei wurde beibehalten" echo "✓ Bestehende .env-Datei wurde beibehalten"
else else