Entkopple Partnersuche vom Altersabstand und entferne garantierte Schwangerschaften
All checks were successful
Deploy to production / deploy (push) Successful in 2m41s

This commit is contained in:
Torsten Schulz (local)
2026-11-04 15:09:49 +01:00
parent 0178d33a21
commit 6e31a114cf
4 changed files with 98 additions and 148 deletions

View File

@@ -31,17 +31,28 @@ und der [Endocrine Society zur zeitlichen Entwicklung](https://www.endocrine.org
## Spielregeln und API ## Spielregeln und API
Die Heiratsgrenze bleibt unabhängig davon bei 14 Spieljahren für beide Die Werbung bzw. Partnersuche beginnt ab 10, die Heiratsgrenze bleibt unabhängig
Verlobten. Automatische Schwangerschaften in bestehenden Ehen, Verlobungen und Liebschaften davon bei 14 Spieljahren für beide Verlobten. Partnervorschläge haben keine
sowie der Backend-Sonderweg nach einer Hochzeit prüfen das individuelle Altersabstandsgrenze und keine Altersobergrenze. Bis zu fünf Kandidaten werden
Fruchtbarkeitsalter beider Eltern. Verlobungen bleiben als Beziehungsstatus erhalten. 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. Verlobte benötigen keinen Liebschaftsunterhalt oder vorherigen Abrechnungslauf.
Die Zuneigung muss mindestens 45 betragen; bei fehlendem Beziehungszustand gilt Die Zuneigung muss mindestens 45 betragen; bei fehlendem Beziehungszustand gilt
wie im Backend der Standardwert 50. Inaktive Beziehungen und verstorbene Partner 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 sind ausgeschlossen. Die Chance beträgt für Ehen, Verlobungen und sämtliche
zwei Prozentpunkte mehr; hohe Sichtbarkeit bei Zuneigung unter 50 reduziert sie Liebschaftsrollen einheitlich 50 Prozent pro regulärer Fertilitätsprüfung. Es gibt
um zwei Prozentpunkte. Schwangerschaftsdauer und Sperren gegen parallele keine rollen-, zuneigungs- oder sichtbarkeitsabhängigen Chancenboni. Die
Schwangerschaften entsprechen dem bestehenden Liebschaftsweg. 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 Eine Geburt während der Verlobung nutzt die vorhandene Kennzeichnung für
außereheliche Kinder (`birth_context = 'lover'`, zunächst `hidden_bastard` und 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. Auch verheiratete Charaktere können mit einer Liebschaft ein Kind bekommen.
Bei einer Mutter blockiert eine bereits geplante Schwangerschaft weitere Bei einer Mutter blockiert eine bereits geplante Schwangerschaft weitere
Konzeptionen unabhängig vom Partner. Der Eheweg hat eine Chance von 72 Prozent Konzeptionen unabhängig vom Partner. Liebschaften benötigen Zuneigung ab 45, Unterhalt ab
pro regulärer Prüfung; der Liebschaftsweg verwendet je nach Rolle und Zustand
deutlich kleinere Chancen. Liebschaften benötigen Zuneigung ab 45, Unterhalt ab
30 und einen aktuellen Abrechnungsstempel. Ein Ehepartner wird bei einer bereits 30 und einen aktuellen Abrechnungsstempel. Ein Ehepartner wird bei einer bereits
geplanten Liebschaftsschwangerschaft nicht nachträglich als Vater eingesetzt. geplanten Liebschaftsschwangerschaft nicht nachträglich als Vater eingesetzt.

View File

@@ -5,7 +5,8 @@ import FalukantPredefineFirstname from '../models/falukant/predefine/firstname.j
import FalukantPredefineLastname from '../models/falukant/predefine/lastname.js'; import FalukantPredefineLastname from '../models/falukant/predefine/lastname.js';
import FalukantUser from '../models/falukant/data/user.js'; import FalukantUser from '../models/falukant/data/user.js';
import FalukantCharacter from '../models/falukant/data/character.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 RegionData from '../models/falukant/data/region.js';
import RegionType from '../models/falukant/type/region.js'; import RegionType from '../models/falukant/type/region.js';
import FalukantStock from '../models/falukant/data/stock.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) { async getFamily(hashedUserId) {
const user = await this.getFalukantUserByHashedId(hashedUserId); const user = await this.getFalukantUserByHashedId(hashedUserId);
if (!user) throw new Error('User not found'); 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; if (!child.hasName || child.relationship || !kid || calcAge(kid.birthdate) < FAMILY_AGE.MIN_WOOING) continue;
child.possiblePartners = await this.getPossiblePartners(kid.id); child.possiblePartners = await this.getPossiblePartners(kid.id);
if (child.possiblePartners.length === 0) { 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); child.possiblePartners = await this.getPossiblePartners(kid.id);
} }
} }
@@ -4039,25 +3938,11 @@ class FalukantService extends BaseService {
character.id, character.id,
character.gender, character.gender,
character.regionId, character.regionId,
character.titleOfNobility, character.titleOfNobility
ownAge
); );
family.possiblePartners = await this.getPossiblePartners(character.id); 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; return family;
} }
@@ -4663,7 +4548,7 @@ class FalukantService extends BaseService {
}); });
} }
async createPossiblePartners(requestingCharacterId, requestingCharacterGender, requestingRegionId, requestingCharacterTitleOfNobility, ownAge) { async createPossiblePartners(requestingCharacterId, requestingCharacterGender, requestingRegionId, requestingCharacterTitleOfNobility) {
try { try {
const minTitleResult = await TitleOfNobility.findOne({ const minTitleResult = await TitleOfNobility.findOne({
order: [['id', 'ASC']], order: [['id', 'ASC']],
@@ -4674,20 +4559,13 @@ class FalukantService extends BaseService {
} }
const minTitle = minTitleResult.id; const minTitle = minTitleResult.id;
const potentialPartners = await FalukantCharacter.findAll({ const potentialPartners = await FalukantCharacter.findAll(buildPartnerCandidateQuery({
where: { characterId: requestingCharacterId,
id: { [Op.ne]: requestingCharacterId }, gender: requestingCharacterGender,
gender: { [Op.ne]: requestingCharacterGender }, regionId: requestingRegionId,
regionId: requestingRegionId, titleId: requestingCharacterTitleOfNobility,
birthdate: { [Op.lte]: new Date(Date.now() - FAMILY_AGE.MIN_WOOING * 24 * 60 * 60 * 1000) }, minimumAge: FAMILY_AGE.MIN_WOOING,
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 proposals = potentialPartners.map(partner => { const proposals = potentialPartners.map(partner => {
const age = calcAge(partner.birthdate); const age = calcAge(partner.birthdate);

View File

@@ -1,9 +1,10 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises'; import { readFile } from 'node:fs/promises';
import test from 'node:test'; 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 migration from '../migrations-active/20261104000000-add-character-development-ages.cjs';
import { getCharacterDevelopment, hasReachedFertility } from '../utils/falukant/characterDevelopment.js'; 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', () => { test('development follows each character, independently of the marriage age', () => {
const early = { pubertyStartAge: 10, fertileFromAge: 13 }; 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 await assert.rejects(db.query(`INSERT INTO falukant_data.character
(puberty_start_age, fertile_from_age) VALUES (12, 11)`)); (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 () => { 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'); const [saved] = await db.query('SELECT * FROM falukant_data.character ORDER BY id');
await migration.up(qi); await migration.up(qi);
@@ -100,11 +120,11 @@ test('database migration, persistence and daemon conception paths', {
skip: !process.env.DAEMON_SQL_PATH, skip: !process.env.DAEMON_SQL_PATH,
}, async (t) => { }, async (t) => {
const source = await readFile(process.env.DAEMON_SQL_PATH, 'utf8'); 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]*?)"#;`)); const match = source.match(new RegExp(`pub const ${name}: &str = r#"([\\s\\S]*?)"#;`));
assert.ok(match, name); assert.ok(match, name);
// Remove chance from these eligibility tests only. // 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(` await db.query(`
TRUNCATE falukant_data.character RESTART IDENTITY; 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 () => { 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); 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', await db.query(`UPDATE falukant_data.character SET birthdate = CURRENT_DATE - INTERVAL '13 days',

View File

@@ -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,
};
}