diff --git a/backend/docs/falukant-character-development.md b/backend/docs/falukant-character-development.md index 3310b74..fa42a32 100644 --- a/backend/docs/falukant-character-development.md +++ b/backend/docs/falukant-character-development.md @@ -31,17 +31,28 @@ 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, Verlobungen und Liebschaften -sowie der Backend-Sonderweg nach einer Hochzeit prüfen das individuelle -Fruchtbarkeitsalter beider Eltern. Verlobungen bleiben als Beziehungsstatus erhalten. +Die Werbung bzw. Partnersuche beginnt ab 10, die Heiratsgrenze bleibt unabhängig +davon bei 14 Spieljahren für beide Verlobten. Partnervorschläge haben keine +Altersabstandsgrenze und keine Altersobergrenze. Bis zu fünf Kandidaten werden +zufällig aus der passenden Region und Titelgruppe ausgewählt; ein ähnliches Alter +und das Erstellungsdatum des Charakters sind keine Auswahlkriterien. + +Automatische Schwangerschaften in bestehenden Ehen, Verlobungen und Liebschaften +prüfen das individuelle 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. +sind ausgeschlossen. Die Chance beträgt für Ehen, Verlobungen und sämtliche +Liebschaftsrollen einheitlich 50 Prozent pro regulärer Fertilitätsprüfung. Es gibt +keine rollen-, zuneigungs- oder sichtbarkeitsabhängigen Chancenboni. Die +Voraussetzungen für eine Prüfung gelten weiterhin. Schwangerschaftsdauer und +Sperren gegen parallele Schwangerschaften entsprechen dem bestehenden Weg. + +Liebschaften und Verlobungen werden nur im Jahres-/Abrechnungslauf geprüft, +nicht zusätzlich im Tageslauf. Der garantierte Backend-Sonderweg nach einer +Hochzeit entfällt; das Aufrufen der Familienseite plant keine Schwangerschaft. +Bereits geplante Schwangerschaften und frühere Hochzeitsflags bleiben erhalten. Eine Geburt während der Verlobung nutzt die vorhandene Kennzeichnung für außereheliche Kinder (`birth_context = 'lover'`, zunächst `hidden_bastard` und @@ -51,9 +62,7 @@ erhalten. Für diese Freischaltung ist keine weitere Datenbankmigration nötig. Auch verheiratete Charaktere können mit einer Liebschaft ein Kind bekommen. Bei einer Mutter blockiert eine bereits geplante Schwangerschaft weitere -Konzeptionen unabhängig vom Partner. Der Eheweg hat eine Chance von 72 Prozent -pro regulärer Prüfung; der Liebschaftsweg verwendet je nach Rolle und Zustand -deutlich kleinere Chancen. Liebschaften benötigen Zuneigung ab 45, Unterhalt ab +Konzeptionen unabhängig vom Partner. Liebschaften benötigen Zuneigung ab 45, Unterhalt ab 30 und einen aktuellen Abrechnungsstempel. Ein Ehepartner wird bei einer bereits geplanten Liebschaftsschwangerschaft nicht nachträglich als Vater eingesetzt. diff --git a/backend/services/falukantService.js b/backend/services/falukantService.js index 0a0c084..6811326 100755 --- a/backend/services/falukantService.js +++ b/backend/services/falukantService.js @@ -5,7 +5,8 @@ import FalukantPredefineFirstname from '../models/falukant/predefine/firstname.j import FalukantPredefineLastname from '../models/falukant/predefine/lastname.js'; import FalukantUser from '../models/falukant/data/user.js'; import FalukantCharacter from '../models/falukant/data/character.js'; -import { getCharacterDevelopment, hasReachedFertility } from '../utils/falukant/characterDevelopment.js'; +import { getCharacterDevelopment } from '../utils/falukant/characterDevelopment.js'; +import { buildPartnerCandidateQuery } from '../utils/falukant/partnerCandidates.js'; import RegionData from '../models/falukant/data/region.js'; import RegionType from '../models/falukant/type/region.js'; import FalukantStock from '../models/falukant/data/stock.js'; @@ -3610,108 +3611,6 @@ class FalukantService extends BaseService { } } - /** - * Nach der Trauung (relationship = married): geplante Geburt auf der Mutter-Charakterzeile setzen, - * damit der Daemon den UserCharacterWorker-Pfad mit pregnancy_due_at nutzen kann. - * Läuft idempotent pro Ehe über relationship_state.flags_json.honeymoonPregnancyScheduled. - * @returns {{ scheduled: boolean, motherCharacterId?: number }} - */ - async _ensureMarriageHoneymoonPregnancyScheduled({ - playerCharacterId, - playerGender, - spouseCharacterId, - spouseGender, - relationshipId, - }) { - if (!relationshipId || !playerCharacterId || !spouseCharacterId) { - return { scheduled: false }; - } - const norm = (g) => String(g || '').trim().toLowerCase(); - const isFemale = (g) => { - const s = norm(g); - return s === 'female' || s === 'f'; - }; - const isMale = (g) => { - const s = norm(g); - return s === 'male' || s === 'm'; - }; - let motherId; - let fatherId; - if (isFemale(playerGender) && isMale(spouseGender)) { - motherId = playerCharacterId; - fatherId = spouseCharacterId; - } else if (isFemale(spouseGender) && isMale(playerGender)) { - motherId = spouseCharacterId; - fatherId = playerCharacterId; - } else { - return { scheduled: false }; - } - - const parents = await FalukantCharacter.unscoped().findAll({ - where: { id: [motherId, fatherId] }, - attributes: ['id', 'birthdate', 'fertileFromAge'], - }); - if ( - parents.length !== 2 - || parents.some((parent) => !hasReachedFertility(parent, calcAge(parent.birthdate))) - ) { - return { scheduled: false }; - } - - const stateRow = await RelationshipState.findOne({ where: { relationshipId } }); - const flags = stateRow?.flagsJson && typeof stateRow.flagsJson === 'object' - ? { ...stateRow.flagsJson } - : {}; - if (flags.honeymoonPregnancyScheduled) { - return { scheduled: false }; - } - - const pregRows = await sequelize.query( - `SELECT pregnancy_due_at FROM falukant_data."character" WHERE id = :id`, - { replacements: { id: motherId }, type: Sequelize.QueryTypes.SELECT } - ); - if (pregRows[0]?.pregnancy_due_at) { - return { scheduled: false }; - } - - // Automatische Hochzeits-Schwangerschaften nutzen die kurze Spielzeit von 18 Stunden. - const BIRTH_DUE_HOURS = 18; - const due = new Date(); - due.setHours(due.getHours() + BIRTH_DUE_HOURS); - - await FalukantCharacter.unscoped().update( - { - pregnancyDueAt: due, - pregnancyFatherCharacterId: fatherId, - }, - { where: { id: motherId } } - ); - - const mergedFlags = { ...flags, honeymoonPregnancyScheduled: true }; - if (stateRow) { - await stateRow.update({ flagsJson: mergedFlags }); - } else { - await RelationshipState.create({ - relationshipId, - ...this.buildDefaultRelationshipState('married'), - flagsJson: mergedFlags, - }); - } - - const mother = await FalukantCharacter.unscoped().findByPk(motherId, { attributes: ['id', 'userId'] }); - if (mother?.userId) { - const fu = await FalukantUser.findByPk(mother.userId); - if (fu) { - const u = await User.findByPk(fu.userId); - if (u?.hashedId) { - await notifyUser(u.hashedId, 'familychanged', {}); - } - } - } - - return { scheduled: true, motherCharacterId: motherId }; - } - async getFamily(hashedUserId) { const user = await this.getFalukantUserByHashedId(hashedUserId); if (!user) throw new Error('User not found'); @@ -3926,7 +3825,7 @@ class FalukantService extends BaseService { 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)); + await this.createPossiblePartners(kid.id, kid.gender, kid.regionId, kid.titleOfNobility); child.possiblePartners = await this.getPossiblePartners(kid.id); } } @@ -4039,25 +3938,11 @@ class FalukantService extends BaseService { character.id, character.gender, character.regionId, - character.titleOfNobility, - ownAge + character.titleOfNobility ); family.possiblePartners = await this.getPossiblePartners(character.id); } } - const marriedRel = family.relationships.find((r) => r.relationshipType === 'married'); - if (marriedRel?.id && marriedRel.character2?.id != null) { - const honeymoon = await this._ensureMarriageHoneymoonPregnancyScheduled({ - playerCharacterId: character.id, - playerGender: character.gender, - spouseCharacterId: marriedRel.character2.id, - spouseGender: marriedRel.character2.gender, - relationshipId: marriedRel.id, - }); - if (honeymoon.scheduled && honeymoon.motherCharacterId === character.id) { - family.pregnancy = await this._getCharacterPregnancyOptional(character.id); - } - } return family; } @@ -4663,7 +4548,7 @@ class FalukantService extends BaseService { }); } - async createPossiblePartners(requestingCharacterId, requestingCharacterGender, requestingRegionId, requestingCharacterTitleOfNobility, ownAge) { + async createPossiblePartners(requestingCharacterId, requestingCharacterGender, requestingRegionId, requestingCharacterTitleOfNobility) { try { const minTitleResult = await TitleOfNobility.findOne({ order: [['id', 'ASC']], @@ -4674,20 +4559,13 @@ class FalukantService extends BaseService { } const minTitle = minTitleResult.id; - const potentialPartners = await FalukantCharacter.findAll({ - where: { - id: { [Op.ne]: requestingCharacterId }, - gender: { [Op.ne]: requestingCharacterGender }, - regionId: requestingRegionId, - birthdate: { [Op.lte]: new Date(Date.now() - FAMILY_AGE.MIN_WOOING * 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] } - }, - order: [ - [Sequelize.literal(`ABS((EXTRACT(EPOCH FROM (NOW() - "birthdate")) / 86400) - ${ownAge})`), 'ASC'] - ], - limit: 5, - }); + const potentialPartners = await FalukantCharacter.findAll(buildPartnerCandidateQuery({ + characterId: requestingCharacterId, + gender: requestingCharacterGender, + regionId: requestingRegionId, + titleId: requestingCharacterTitleOfNobility, + minimumAge: FAMILY_AGE.MIN_WOOING, + })); const proposals = potentialPartners.map(partner => { const age = calcAge(partner.birthdate); diff --git a/backend/tools/characterDevelopment.test.mjs b/backend/tools/characterDevelopment.test.mjs index 77a375a..61deb4d 100644 --- a/backend/tools/characterDevelopment.test.mjs +++ b/backend/tools/characterDevelopment.test.mjs @@ -1,9 +1,10 @@ import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import test from 'node:test'; -import { Sequelize } from 'sequelize'; +import { Sequelize, DataTypes } from 'sequelize'; import migration from '../migrations-active/20261104000000-add-character-development-ages.cjs'; import { getCharacterDevelopment, hasReachedFertility } from '../utils/falukant/characterDevelopment.js'; +import { buildPartnerCandidateQuery } from '../utils/falukant/partnerCandidates.js'; test('development follows each character, independently of the marriage age', () => { const early = { pubertyStartAge: 10, fertileFromAge: 13 }; @@ -71,6 +72,25 @@ test('database migration, persistence and daemon conception paths', { await assert.rejects(db.query(`INSERT INTO falukant_data.character (puberty_start_age, fertile_from_age) VALUES (12, 11)`)); + await t.test('partner candidates have a minimum age without age-gap or creation-age restrictions', async () => { + const [inserted] = await db.query(`INSERT INTO falukant_data.character (gender, birthdate) + VALUES ('male', CURRENT_DATE - INTERVAL '65 days'), + ('male', CURRENT_DATE - INTERVAL '10 days' + INTERVAL '12 hours'), + ('male', CURRENT_DATE - INTERVAL '9 days') + RETURNING id`); + const Candidate = db.define('DevelopmentPartnerCandidate', { + id: { type: DataTypes.INTEGER, primaryKey: true }, + gender: DataTypes.STRING, + birthdate: DataTypes.DATE, + regionId: { type: DataTypes.INTEGER, field: 'region_id' }, + titleOfNobility: { type: DataTypes.INTEGER, field: 'title_of_nobility' }, + }, { schema: 'falukant_data', tableName: 'character', timestamps: false }); + const rows = await Candidate.findAll(buildPartnerCandidateQuery({ + characterId: 1, gender: 'female', regionId: 1, titleId: 1, minimumAge: 10, + })); + assert.deepEqual(rows.map(c => c.id).sort((a, b) => a - b), [inserted[0].id, inserted[1].id]); + }); + await t.test('repeating the migration preserves all existing character data', async () => { const [saved] = await db.query('SELECT * FROM falukant_data.character ORDER BY id'); await migration.up(qi); @@ -100,11 +120,11 @@ test('database migration, persistence and daemon conception paths', { skip: !process.env.DAEMON_SQL_PATH, }, async (t) => { const source = await readFile(process.env.DAEMON_SQL_PATH, 'utf8'); - const sql = (name) => { + const sql = (name, randomRoll = 0) => { const match = source.match(new RegExp(`pub const ${name}: &str = r#"([\\s\\S]*?)"#;`)); assert.ok(match, name); // Remove chance from these eligibility tests only. - return match[1].replaceAll('random()', '0.0'); + return match[1].replaceAll('random()', `${randomRoll}::double precision`); }; await db.query(` TRUNCATE falukant_data.character RESTART IDENTITY; @@ -154,6 +174,29 @@ test('database migration, persistence and daemon conception paths', { } } } + await t.test('marriage, engagement and every lover role use exactly fifty percent', async () => { + for (const [name, type, role] of [ + ['QUERY_TRY_MARRIAGE_CONCEPTION_UPDATE', 1, null], + ['QUERY_GET_LEGACY_MARRIAGE_INSTANT_PREGNANCY_CANDIDATES', 1, null], + ['QUERY_GET_LOVER_PREGNANCY_CANDIDATES', 3, null], + ...['secret_affair', 'lover', 'mistress_or_favorite'].map(role => ['QUERY_GET_LOVER_PREGNANCY_CANDIDATES', 2, role]), + ]) { + for (const [roll, expected] of [[0.499, true], [0.5, false], [0.99, false]]) { + await db.query(`UPDATE falukant_data.character SET birthdate = CURRENT_DATE - INTERVAL '20 days', pregnancy_due_at = NULL; + UPDATE falukant_data.relationship SET relationship_type_id = :type, marriage_pregnancy_due_at = NULL; + UPDATE falukant_data.relationship_state SET lover_role = :role, affection = 80, visibility = 80`, { + replacements: { type, role }, + }); + const [rows] = await db.query(sql(name, roll)); + if (name === 'QUERY_TRY_MARRIAGE_CONCEPTION_UPDATE') { + const [[row]] = await db.query('SELECT marriage_pregnancy_due_at FROM falukant_data.relationship'); + assert.equal(row.marriage_pregnancy_due_at != null, expected, `${name}/${role}/${roll}`); + } else { + assert.equal(rows.length > 0, expected, `${name}/${role}/${roll}`); + } + } + } + }); 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', diff --git a/backend/utils/falukant/partnerCandidates.js b/backend/utils/falukant/partnerCandidates.js new file mode 100644 index 0000000..18f7576 --- /dev/null +++ b/backend/utils/falukant/partnerCandidates.js @@ -0,0 +1,20 @@ +import { Op, Sequelize } from 'sequelize'; + +// One calendar day is one game year. Only the minimum age restricts age; +// neither the age difference nor the date the character was created matters. +export function buildPartnerCandidateQuery({ characterId, gender, regionId, titleId, minimumAge, now = new Date() }) { + const minimumAgeCutoff = new Date(now); + minimumAgeCutoff.setHours(0, 0, 0, 0); + minimumAgeCutoff.setDate(minimumAgeCutoff.getDate() - minimumAge + 1); + return { + where: { + id: { [Op.ne]: characterId }, + gender: { [Op.ne]: gender }, + regionId, + birthdate: { [Op.lt]: minimumAgeCutoff }, + titleOfNobility: { [Op.between]: [titleId - 1, titleId + 1] }, + }, + order: Sequelize.literal('random()'), + limit: 5, + }; +}