Entkopple Partnersuche vom Altersabstand und entferne garantierte Schwangerschaften
All checks were successful
Deploy to production / deploy (push) Successful in 2m41s
All checks were successful
Deploy to production / deploy (push) Successful in 2m41s
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
20
backend/utils/falukant/partnerCandidates.js
Normal file
20
backend/utils/falukant/partnerCandidates.js
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user