diff --git a/backend/docs/falukant-character-development.md b/backend/docs/falukant-character-development.md index 8a22aaf..04bd0d1 100644 --- a/backend/docs/falukant-character-development.md +++ b/backend/docs/falukant-character-development.md @@ -32,10 +32,22 @@ und der [Endocrine Society zur zeitlichen Entwicklung](https://www.endocrine.org ## Spielregeln und API Die Heiratsgrenze bleibt unabhängig davon bei 14 Spieljahren für beide -Verlobten. Automatische Schwangerschaften in bestehenden Ehen und Liebschaften +Verlobten. Automatische Schwangerschaften in bestehenden Ehen, Verlobungen und Liebschaften sowie der Backend-Sonderweg nach einer Hochzeit prüfen das individuelle -Fruchtbarkeitsalter beider Eltern. Die bisherigen Beziehungsarten, -Schwangerschaftschancen und Schwangerschaftsdauern gelten weiterhin. +Fruchtbarkeitsalter beider Eltern. Verlobungen bleiben als Beziehungsstatus erhalten. +Verlobte benötigen keinen Liebschaftsunterhalt oder vorherigen Abrechnungslauf. +Die Zuneigung muss mindestens 45 betragen; bei fehlendem Beziehungszustand gilt +wie im Backend der Standardwert 50. Inaktive Beziehungen und verstorbene Partner +sind ausgeschlossen. Die Chance beträgt pro Prüfung 4 Prozent, ab Zuneigung 75 +zwei Prozentpunkte mehr; hohe Sichtbarkeit bei Zuneigung unter 50 reduziert sie +um zwei Prozentpunkte. Schwangerschaftsdauer und Sperren gegen parallele +Schwangerschaften entsprechen dem bestehenden Liebschaftsweg. + +Eine Geburt während der Verlobung nutzt die vorhandene Kennzeichnung für +außereheliche Kinder (`birth_context = 'lover'`, zunächst `hidden_bastard` und +nicht öffentlich bekannt). Heiratet das Paar vor der Geburt, wird das Kind über +den bestehenden Geburtsweg als ehelich erfasst. Die Schwangerschaft bleibt dabei +erhalten. Für diese Freischaltung ist keine weitere Datenbankmigration nötig. Eine bereits geplante Geburt wird nicht nachträglich durch die neuen Werte blockiert. Die Migration verändert weder Schwangerschaftstermine noch diff --git a/backend/tools/characterDevelopment.test.mjs b/backend/tools/characterDevelopment.test.mjs index 4390616..6151668 100644 --- a/backend/tools/characterDevelopment.test.mjs +++ b/backend/tools/characterDevelopment.test.mjs @@ -98,7 +98,7 @@ test('database migration, persistence and daemon conception paths', { await t.test('daemon checks both parents and preserves already scheduled births', { skip: !process.env.DAEMON_SQL_PATH, - }, async () => { + }, async (t) => { const source = await readFile(process.env.DAEMON_SQL_PATH, 'utf8'); const sql = (name) => { const match = source.match(new RegExp(`pub const ${name}: &str = r#"([\\s\\S]*?)"#;`)); @@ -109,13 +109,17 @@ test('database migration, persistence and daemon conception paths', { await db.query(` TRUNCATE falukant_data.character RESTART IDENTITY; CREATE TABLE falukant_type.relationship (id INT PRIMARY KEY, tr TEXT); - INSERT INTO falukant_type.relationship VALUES (1, 'married'), (2, 'lover'); + INSERT INTO falukant_type.relationship VALUES (1, 'married'), (2, 'lover'), (3, 'engaged'), (4, 'wooing'); CREATE TABLE falukant_data.relationship ( id INT PRIMARY KEY, character1_id INT, character2_id INT, relationship_type_id INT, marriage_pregnancy_due_at TIMESTAMPTZ ); CREATE TABLE falukant_data.falukant_user (id INT PRIMARY KEY, user_id INT); - CREATE TABLE falukant_data.child_relation (mother_character_id INT, created_at TIMESTAMPTZ); + CREATE TABLE falukant_data.child_relation ( + father_character_id INT, mother_character_id INT, child_character_id INT, + name_set BOOLEAN, legitimacy TEXT, birth_context TEXT, public_known BOOLEAN, + created_at TIMESTAMPTZ, updated_at TIMESTAMPTZ + ); CREATE TABLE falukant_data.relationship_state ( relationship_id INT, active BOOLEAN, affection INT, maintenance_level INT, last_monthly_processed_at TIMESTAMPTZ, lover_role TEXT, visibility INT @@ -131,6 +135,7 @@ test('database migration, persistence and daemon conception paths', { ['QUERY_TRY_MARRIAGE_CONCEPTION_UPDATE', 1], ['QUERY_GET_LEGACY_MARRIAGE_INSTANT_PREGNANCY_CANDIDATES', 1], ['QUERY_GET_LOVER_PREGNANCY_CANDIDATES', 2], + ['QUERY_GET_LOVER_PREGNANCY_CANDIDATES', 3], ]) { for (const [motherAge, fatherAge, eligible] of [[13, 13, true], [12, 13, false], [13, 12, false]]) { await db.query(`UPDATE falukant_data.character SET birthdate = CURRENT_DATE - @@ -149,6 +154,73 @@ test('database migration, persistence and daemon conception paths', { } } } + await t.test('engaged couples need no lover maintenance and retain their relationship', async () => { + const candidates = () => db.query(sql('QUERY_GET_LOVER_PREGNANCY_CANDIDATES')).then(([rows]) => rows); + await db.query(`UPDATE falukant_data.character SET birthdate = CURRENT_DATE - INTERVAL '13 days', + pregnancy_due_at = NULL, health = 100; + UPDATE falukant_data.relationship SET relationship_type_id = 3, marriage_pregnancy_due_at = NULL; + UPDATE falukant_data.relationship_state SET affection = 50, lover_role = NULL, + maintenance_level = 0, last_monthly_processed_at = NULL`); + let rows = await candidates(); + assert.equal(rows.length, 1); + assert.equal(rows[0].relationship_type, 'engaged'); + assert.equal(rows[0].mother_cid, 1); + assert.equal(rows[0].father_cid, 2); + // Selection and conception must work for either partner as the player. + await db.query('INSERT INTO falukant_data.falukant_user VALUES (10, 100)'); + for (const playerId of [1, 2]) { + await db.query('UPDATE falukant_data.character SET user_id = CASE WHEN id = :id THEN 10 ELSE NULL END', { + replacements: { id: playerId }, + }); + rows = await candidates(); + assert.equal(rows.length, 1); + assert.equal(rows[0][playerId === 1 ? 'mother_uid' : 'father_uid'], 10); + } + await db.query('UPDATE falukant_data.relationship SET character1_id = 2, character2_id = 1'); + rows = await candidates(); + assert.equal(rows[0].mother_cid, 1); + assert.equal(rows[0].father_cid, 2); + + for (const id of [1, 2]) { + await db.query('UPDATE falukant_data.character SET health = 0 WHERE id = :id', { replacements: { id } }); + assert.equal((await candidates()).length, 0); + await db.query('UPDATE falukant_data.character SET health = 100'); + } + await db.query('UPDATE falukant_data.relationship_state SET active = false'); + assert.equal((await candidates()).length, 0); + await db.query('UPDATE falukant_data.relationship_state SET active = true, affection = 44'); + assert.equal((await candidates()).length, 0); + await db.query('DELETE FROM falukant_data.relationship_state'); + assert.equal((await candidates()).length, 1); + for (const type of [1, 2, 4]) { + await db.query('UPDATE falukant_data.relationship SET relationship_type_id = :type', { replacements: { type } }); + assert.equal((await candidates()).length, 0); + } + await db.query('UPDATE falukant_data.relationship SET relationship_type_id = 3'); + await db.query(sql('QUERY_SET_CHARACTER_PREGNANCY_FROM_LOVER'), { bind: [1, 2] }); + assert.equal((await candidates()).length, 0, 'Existing pregnancy blocks another conception'); + const [[pregnancy]] = await db.query('SELECT pregnancy_due_at, pregnancy_father_character_id FROM falukant_data.character WHERE id = 1'); + assert.equal(pregnancy.pregnancy_father_character_id, 2); + assert.ok(pregnancy.pregnancy_due_at > new Date()); + const [notDue] = await db.query(sql('QUERY_GET_PLANNED_CHARACTER_BIRTH_DELIVERIES')); + assert.equal(notDue.length, 0); + await db.query("UPDATE falukant_data.character SET pregnancy_due_at = NOW() - INTERVAL '1 hour' WHERE id = 1"); + for (const [type, context] of [[3, 'lover'], [1, 'marriage']]) { + await db.query('UPDATE falukant_data.relationship SET relationship_type_id = :type', { replacements: { type } }); + const [births] = await db.query(sql('QUERY_GET_PLANNED_CHARACTER_BIRTH_DELIVERIES')); + assert.equal(births.length, 1); + assert.equal(births[0].birth_context, context); + const [[stillPregnant]] = await db.query('SELECT pregnancy_father_character_id FROM falukant_data.character WHERE id = 1'); + assert.equal(stillPregnant.pregnancy_father_character_id, 2); + } + await db.query('UPDATE falukant_data.relationship SET relationship_type_id = 3'); + await db.query(sql('QUERY_INSERT_CHILD_RELATION_PLANNED_BIRTH'), { bind: [2, 1, 3, 'lover'] }); + const [[child]] = await db.query('SELECT legitimacy, birth_context, public_known FROM falukant_data.child_relation'); + assert.deepEqual(child, { legitimacy: 'hidden_bastard', birth_context: 'lover', public_known: false }); + const [[relationship]] = await db.query('SELECT relationship_type_id FROM falukant_data.relationship'); + assert.equal(relationship.relationship_type_id, 3); + await db.query('DELETE FROM falukant_data.child_relation'); + }); await db.query(`UPDATE falukant_data.relationship SET marriage_pregnancy_due_at = NULL`); for (const [motherAge, fatherAge, eligible] of [[12, 13, false], [13, 12, false], [13, 13, true]]) { await db.query(`UPDATE falukant_data.character SET birthdate = CURRENT_DATE - diff --git a/frontend/src/i18n/locales/de/falukant.json b/frontend/src/i18n/locales/de/falukant.json index 331d10b..8be4b42 100755 --- a/frontend/src/i18n/locales/de/falukant.json +++ b/frontend/src/i18n/locales/de/falukant.json @@ -704,7 +704,7 @@ }, "mood": "Stimmung", "progress": "Zuneigung", - "jumpToPartyForm": "Hochzeitsfeier veranstalten (Nötig für Hochzeit und Kinder)" + "jumpToPartyForm": "Hochzeitsfeier veranstalten" }, "marriageState": { "stable": "Stabil", diff --git a/frontend/src/i18n/locales/es/falukant.json b/frontend/src/i18n/locales/es/falukant.json index 3b9fb9e..10ef578 100755 --- a/frontend/src/i18n/locales/es/falukant.json +++ b/frontend/src/i18n/locales/es/falukant.json @@ -692,7 +692,7 @@ }, "mood": "Estado de ánimo", "progress": "Afecto", - "jumpToPartyForm": "Organizar banquete de boda (necesario para boda e hijos)" + "jumpToPartyForm": "Organizar banquete de boda" }, "marriageState": { "stable": "Estable", diff --git a/frontend/src/i18n/locales/fr/falukant.json b/frontend/src/i18n/locales/fr/falukant.json index 0c8abc9..a407ffe 100755 --- a/frontend/src/i18n/locales/fr/falukant.json +++ b/frontend/src/i18n/locales/fr/falukant.json @@ -696,7 +696,7 @@ }, "mood": "Humeur", "progress": "affection", - "jumpToPartyForm": "Organiser une fête de mariage (nécessaire pour le mariage et les enfants)" + "jumpToPartyForm": "Organiser une fête de mariage" }, "marriageState": { "stable": "Écurie",