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
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.

View File

@@ -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);

View File

@@ -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',

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