Ergänze individuelle Charakterentwicklung und klare Hochzeitshinweise
Some checks failed
Deploy to production / deploy (push) Failing after 1m18s

This commit is contained in:
Torsten Schulz (local)
2026-11-04 14:16:11 +01:00
parent fddb97b385
commit 343bf64115
13 changed files with 350 additions and 9 deletions

View File

@@ -0,0 +1,74 @@
# Individuelle Charakterentwicklung
`character.puberty_start_age` und `character.fertile_from_age` sind dauerhaft
gespeicherte ganze Spieljahre. Ein realer Kalendertag entspricht einem Spieljahr.
Die Sequelize-Attribute heißen `pubertyStartAge` und `fertileFromAge`.
## Vergabe
Die Migration `20261104000000-add-character-development-ages.cjs` ergänzt beide
Werte für bestehende Charaktere. Ein Datenbanktrigger initialisiert sie auch für
neue Charaktere, einschließlich der direkten SQL-Inserts des Daemons für NPCs
und Neugeborene. Normale Updates, auch das Übergeben von NULL, würfeln bereits
gespeicherte Werte nicht neu aus. Explizite gültige Änderungen bleiben möglich.
Das vereinfachte Spielmodell verwendet folgende Verteilung:
- Weibliche Charaktere: Pubertätsbeginn zwischen 8 und 13 Spieljahren.
- Männliche und sonstige Charaktere: Pubertätsbeginn zwischen 9 und 14 Spieljahren.
- Zwei gemittelte Zufallsziehungen gewichten die Mitte stärker als die Ränder.
- Fruchtbarkeitsalter: Pubertätsbeginn plus zufällig zwei oder drei Spieljahre.
Die Abstände und Wahrscheinlichkeiten sind Modellentscheidungen. Sie stellen
keine medizinische Vorhersage und keine Schätzung mittelalterlicher
Bevölkerungsdaten dar. Der Beginn der Pubertät und das Erreichen der
Fortpflanzungsfähigkeit werden als getrennte Ereignisse modelliert. Als
Orientierung für die Bandbreite dienen die Informationen des
[NHS zum Pubertätsbeginn](https://www.nhs.uk/conditions/early-or-delayed-puberty/)
und der [Endocrine Society zur zeitlichen Entwicklung](https://www.endocrine.org/patient-engagement/endocrine-library/precocious-puberty).
## Spielregeln und API
Die Heiratsgrenze bleibt unabhängig davon bei 14 Spieljahren für beide
Verlobten. Automatische Schwangerschaften in bestehenden Ehen und Liebschaften
sowie der Backend-Sonderweg nach einer Hochzeit prüfen das individuelle
Fruchtbarkeitsalter beider Eltern. Die bisherigen Beziehungsarten,
Schwangerschaftschancen und Schwangerschaftsdauern gelten weiterhin.
Eine bereits geplante Geburt wird nicht nachträglich durch die neuen Werte
blockiert. Die Migration verändert weder Schwangerschaftstermine noch
Beziehungen. Admin-Funktionen zum expliziten Setzen von Schwangerschaften
bleiben manuelle Eingriffe.
Die Charakterdaten unter `/api/falukant/info` enthalten beide Alterswerte.
Die Familienantwort enthält für den eigenen Charakter, Partner und Kinder
zusätzlich `development` mit beiden Werten sowie `hasReachedPuberty` und
`hasReachedFertility`. Diese booleschen Werte werden beim Abruf aus dem Alter
abgeleitet. Es gibt keine Änderungen an Porträts oder 3D-Modellen.
## Bereitstellung
Zuerst die Backend-Migration ausführen, dann Backend und Daemon gemeinsam
aktualisieren bzw. neu starten. Beide Anwendungen benötigen die neuen Spalten.
Die Daemon-Anpassungen liegen im benachbarten Repository `ypdaemon`, in
`src/worker/sql.rs`. Ein Rollback muss auch die vorherigen Anwendungsversionen
wiederherstellen, bevor die neuen Spalten entfernt werden.
## Prüfung
```sh
cd backend
node --test tools/characterDevelopment.test.mjs
```
Die Datenbanktests sind ohne `TEST_DATABASE_URL` übersprungen. Mit einer
explizit angegebenen, isolierten PostgreSQL-Datenbank namens
`yourpart_development_test` prüfen sie Migration, Backfill, direkte Inserts,
stabile Werte und Rollback. Diese Tests ersetzen die Schemas `falukant_data`
und `falukant_type` ausschließlich in dieser Testdatenbank.
Wird zusätzlich `DAEMON_SQL_PATH` auf `ypdaemon/src/worker/sql.rs` gesetzt,
führen sie die tatsächlichen Daemon-Abfragen aus: beide Eltern genau an und
unter ihrer Altersgrenze, Ehe- und Liebschaftswege sowie bestehende Geburten.
Für die Prüfung der Voraussetzungen wird der Zufallswurf im Test auf null
gesetzt.

View File

@@ -0,0 +1,64 @@
'use strict';
module.exports = {
async up(queryInterface) {
await queryInterface.sequelize.transaction(async (transaction) => {
await queryInterface.sequelize.query(`
ALTER TABLE falukant_data.character
ADD COLUMN puberty_start_age SMALLINT,
ADD COLUMN fertile_from_age SMALLINT;
-- Shared by Sequelize and raw daemon INSERTs, including newborns and NPCs.
-- One real calendar day is one game year. These are game distributions,
-- not medical predictions or reconstructed medieval population data.
CREATE FUNCTION falukant_data.initialize_character_development()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP = 'UPDATE' THEN
NEW.puberty_start_age := COALESCE(NEW.puberty_start_age, OLD.puberty_start_age);
NEW.fertile_from_age := COALESCE(NEW.fertile_from_age, OLD.fertile_from_age);
END IF;
IF NEW.puberty_start_age IS NULL THEN
NEW.puberty_start_age :=
CASE WHEN lower(NEW.gender) IN ('female', 'f') THEN 8 ELSE 9 END
+ floor((random() + random()) * 3)::int;
END IF;
IF NEW.fertile_from_age IS NULL THEN
NEW.fertile_from_age := NEW.puberty_start_age + 2 + floor(random() * 2)::int;
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER initialize_character_development
BEFORE INSERT OR UPDATE ON falukant_data.character
FOR EACH ROW EXECUTE FUNCTION falukant_data.initialize_character_development();
-- Existing pregnancies and relationships are deliberately untouched.
UPDATE falukant_data.character SET puberty_start_age = puberty_start_age;
ALTER TABLE falukant_data.character
ALTER COLUMN puberty_start_age SET NOT NULL,
ALTER COLUMN fertile_from_age SET NOT NULL,
ADD CONSTRAINT character_development_ages_valid CHECK (
puberty_start_age BETWEEN 8 AND 18
AND fertile_from_age > puberty_start_age
AND fertile_from_age <= 21
);
`, { transaction });
});
},
async down(queryInterface) {
await queryInterface.sequelize.transaction(async (transaction) => {
await queryInterface.sequelize.query(`
DROP TRIGGER initialize_character_development ON falukant_data.character;
DROP FUNCTION falukant_data.initialize_character_development();
ALTER TABLE falukant_data.character
DROP CONSTRAINT character_development_ages_valid,
DROP COLUMN puberty_start_age,
DROP COLUMN fertile_from_age;
`, { transaction });
});
}
};

View File

@@ -23,6 +23,17 @@ FalukantCharacter.init(
defaultValue: DataTypes.NOW}, defaultValue: DataTypes.NOW},
gender: { gender: {
type: DataTypes.STRING}, type: DataTypes.STRING},
// Initialized once by the DB trigger, also for raw daemon INSERTs.
// Nullable here so Sequelize lets omitted values reach that trigger;
// the database enforces NOT NULL and the ordering of both ages.
pubertyStartAge: {
type: DataTypes.SMALLINT,
allowNull: true,
},
fertileFromAge: {
type: DataTypes.SMALLINT,
allowNull: true,
},
health: { health: {
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false, allowNull: false,

View File

@@ -5,6 +5,7 @@ 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 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';
@@ -96,7 +97,6 @@ function calcAge(birthdate) {
const FAMILY_AGE = Object.freeze({ const FAMILY_AGE = Object.freeze({
MIN_WOOING: 10, MIN_WOOING: 10,
MIN_MARRIAGE: 14, MIN_MARRIAGE: 14,
MIN_HOUSEHOLD_AND_CHILDREN: 14,
}); });
async function getFalukantUserOrFail(hashedId) { async function getFalukantUserOrFail(hashedId) {
@@ -979,7 +979,7 @@ class FalukantService extends BaseService {
{ model: TitleOfNobility, as: 'nobleTitle', attributes: ['labelTr', 'id', 'level'] } { model: TitleOfNobility, as: 'nobleTitle', attributes: ['labelTr', 'id', 'level'] }
], ],
// reputation: für Adel-Voraussetzungen u.a. (checkReputationRequirement nutzt user.character ohne Nachladen) // reputation: für Adel-Voraussetzungen u.a. (checkReputationRequirement nutzt user.character ohne Nachladen)
attributes: ['id', 'birthdate', 'gender', 'moodId', 'health', 'reputation'] attributes: ['id', 'birthdate', 'gender', 'moodId', 'health', 'reputation', 'pubertyStartAge', 'fertileFromAge']
}, },
] ]
}); });
@@ -1141,7 +1141,7 @@ class FalukantService extends BaseService {
{ {
model: FalukantCharacter, model: FalukantCharacter,
as: 'character', as: 'character',
attributes: ['id', 'birthdate', 'health', 'reputation', 'titleOfNobility'], attributes: ['id', 'birthdate', 'health', 'reputation', 'titleOfNobility', 'pubertyStartAge', 'fertileFromAge'],
include: [ include: [
{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }, { model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] },
{ model: FalukantPredefineLastname, as: 'definedLastName', attributes: ['name'] }, { model: FalukantPredefineLastname, as: 'definedLastName', attributes: ['name'] },
@@ -3649,11 +3649,11 @@ class FalukantService extends BaseService {
const parents = await FalukantCharacter.unscoped().findAll({ const parents = await FalukantCharacter.unscoped().findAll({
where: { id: [motherId, fatherId] }, where: { id: [motherId, fatherId] },
attributes: ['id', 'birthdate'], attributes: ['id', 'birthdate', 'fertileFromAge'],
}); });
if ( if (
parents.length !== 2 parents.length !== 2
|| parents.some((parent) => calcAge(parent.birthdate) < FAMILY_AGE.MIN_HOUSEHOLD_AND_CHILDREN) || parents.some((parent) => !hasReachedFertility(parent, calcAge(parent.birthdate)))
) { ) {
return { scheduled: false }; return { scheduled: false };
} }
@@ -3733,7 +3733,7 @@ class FalukantService extends BaseService {
RelationshipType.findAll({ where: { id: typeIds }, attributes: ['id', 'tr'] }), RelationshipType.findAll({ where: { id: typeIds }, attributes: ['id', 'tr'] }),
FalukantCharacter.findAll({ FalukantCharacter.findAll({
where: { id: char2Ids }, where: { id: char2Ids },
attributes: ['id', 'birthdate', 'gender', 'moodId'], attributes: ['id', 'birthdate', 'gender', 'moodId', 'pubertyStartAge', 'fertileFromAge'],
include: [ include: [
{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }, { model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] },
{ model: TitleOfNobility, as: 'nobleTitle', attributes: ['labelTr'] }, { model: TitleOfNobility, as: 'nobleTitle', attributes: ['labelTr'] },
@@ -3774,6 +3774,7 @@ class FalukantService extends BaseService {
character2: c2 ? { character2: c2 ? {
id: c2.id, id: c2.id,
age: calcAge(c2.birthdate), age: calcAge(c2.birthdate),
development: getCharacterDevelopment(c2, calcAge(c2.birthdate)),
gender: c2.gender, gender: c2.gender,
firstName: c2.definedFirstName?.name || 'Unknown', firstName: c2.definedFirstName?.name || 'Unknown',
nobleTitle: c2.nobleTitle?.labelTr || '', nobleTitle: c2.nobleTitle?.labelTr || '',
@@ -3820,7 +3821,7 @@ class FalukantService extends BaseService {
const childChars = childCharIds.length const childChars = childCharIds.length
? await FalukantCharacter.findAll({ ? await FalukantCharacter.findAll({
where: { id: childCharIds }, where: { id: childCharIds },
attributes: ['id', 'birthdate', 'gender', 'regionId', 'titleOfNobility'], attributes: ['id', 'birthdate', 'gender', 'regionId', 'titleOfNobility', 'pubertyStartAge', 'fertileFromAge'],
include: [{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }] include: [{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }]
}) })
: []; : [];
@@ -3882,6 +3883,7 @@ class FalukantService extends BaseService {
name: kid?.definedFirstName?.name || 'Unknown', name: kid?.definedFirstName?.name || 'Unknown',
gender: kid?.gender, gender: kid?.gender,
age: kid?.birthdate ? calcAge(kid.birthdate) : null, age: kid?.birthdate ? calcAge(kid.birthdate) : null,
development: getCharacterDevelopment(kid, kid?.birthdate ? calcAge(kid.birthdate) : null),
hasName: rel.nameSet, hasName: rel.nameSet,
isHeir: rel.isHeir || false, isHeir: rel.isHeir || false,
legitimacy: rel.legitimacy || 'legitimate', legitimacy: rel.legitimacy || 'legitimate',
@@ -3998,8 +4000,13 @@ class FalukantService extends BaseService {
label: baseTension.label label: baseTension.label
}; };
const family = { const family = {
development: getCharacterDevelopment(character, calcAge(character.birthdate)),
minMarriageAge: FAMILY_AGE.MIN_MARRIAGE,
relationships: activeRelationships.map((r) => ({ relationships: activeRelationships.map((r) => ({
...r, ...r,
canCelebrateWedding: r.relationshipType === 'engaged'
&& calcAge(character.birthdate) >= FAMILY_AGE.MIN_MARRIAGE
&& r.character2?.age >= FAMILY_AGE.MIN_MARRIAGE,
marriageSatisfaction: r.relationshipType === 'married' marriageSatisfaction: r.relationshipType === 'married'
? (r.state?.marriageSatisfaction ?? 100) ? (r.state?.marriageSatisfaction ?? 100)
: null, : null,
@@ -5739,7 +5746,7 @@ class FalukantService extends BaseService {
}); });
const musicTypes = await MusicType.findAll(); const musicTypes = await MusicType.findAll();
const banquetteTypes = await BanquetteType.findAll(); const banquetteTypes = await BanquetteType.findAll();
return { partyTypes, musicTypes, banquetteTypes, weddingCandidates }; return { partyTypes, musicTypes, banquetteTypes, weddingCandidates, minMarriageAge: FAMILY_AGE.MIN_MARRIAGE };
} }
async createParty(hashedUserId, partyTypeId, musicId, banquetteId, nobilityIds = [], servantRatio, relationshipId = null) { async createParty(hashedUserId, partyTypeId, musicId, banquetteId, nobilityIds = [], servantRatio, relationshipId = null) {

View File

@@ -0,0 +1,150 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import { Sequelize } from 'sequelize';
import migration from '../migrations-active/20261104000000-add-character-development-ages.cjs';
import { getCharacterDevelopment, hasReachedFertility } from '../utils/falukant/characterDevelopment.js';
test('development follows each character, independently of the marriage age', () => {
const early = { pubertyStartAge: 10, fertileFromAge: 13 };
const late = { pubertyStartAge: 13, fertileFromAge: 16 };
assert.equal(hasReachedFertility(early, 12), false);
assert.equal(hasReachedFertility(early, 13), true);
assert.equal(hasReachedFertility(late, 14), false);
assert.equal(hasReachedFertility(late, 16), true);
assert.deepEqual(getCharacterDevelopment(early, 11), {
...early, hasReachedPuberty: true, hasReachedFertility: false,
});
for (const missing of [null, {}, { fertileFromAge: null }, { fertileFromAge: 0 }]) {
assert.equal(hasReachedFertility(missing, 30), false);
}
assert.equal(getCharacterDevelopment(early, null).hasReachedPuberty, false);
});
// Only an explicitly provided, disposable test database may be used.
// DAEMON_SQL_PATH additionally exercises the actual Rust SQL against PostgreSQL.
test('database migration, persistence and daemon conception paths', {
skip: !process.env.TEST_DATABASE_URL,
}, async (t) => {
const db = new Sequelize(process.env.TEST_DATABASE_URL, { logging: false });
try {
const [[{ name }]] = await db.query('SELECT current_database() AS name');
assert.equal(name, 'yourpart_development_test', 'Use the isolated development test database');
await db.query(`
DROP SCHEMA IF EXISTS falukant_data CASCADE;
DROP SCHEMA IF EXISTS falukant_type CASCADE;
CREATE SCHEMA falukant_data;
CREATE SCHEMA falukant_type;
CREATE TABLE falukant_data.character (
id SERIAL PRIMARY KEY, gender TEXT, birthdate TIMESTAMPTZ DEFAULT NOW(),
user_id INT, title_of_nobility INT DEFAULT 1, last_name INT DEFAULT 1,
region_id INT DEFAULT 1, health INT DEFAULT 100,
pregnancy_due_at TIMESTAMPTZ, pregnancy_father_character_id INT,
updated_at TIMESTAMPTZ DEFAULT NOW()
);
INSERT INTO falukant_data.character (gender, pregnancy_due_at)
VALUES ('female', NOW() + INTERVAL '1 hour'), ('male', NULL);
`);
const qi = db.getQueryInterface();
await migration.up(qi);
const [before] = await db.query('SELECT * FROM falukant_data.character ORDER BY id');
assert.ok(before[0].pregnancy_due_at);
assert.equal(before[0].puberty_start_age >= 8, true);
assert.equal(before[0].fertile_from_age > before[0].puberty_start_age, true);
await db.query("UPDATE falukant_data.character SET health = 99, puberty_start_age = NULL, fertile_from_age = NULL");
const [after] = await db.query('SELECT * FROM falukant_data.character ORDER BY id');
assert.deepEqual(after.map(c => [c.puberty_start_age, c.fertile_from_age]),
before.map(c => [c.puberty_start_age, c.fertile_from_age]));
assert.deepEqual(after[0].pregnancy_due_at, before[0].pregnancy_due_at);
// Raw inserts represent newborns/NPCs created outside Sequelize.
await db.query(`INSERT INTO falukant_data.character (gender)
SELECT CASE WHEN i % 2 = 0 THEN 'female' ELSE 'male' END
FROM generate_series(1, 1000) i`);
const [[stats]] = await db.query(`SELECT
count(DISTINCT puberty_start_age)::int AS starts,
count(DISTINCT fertile_from_age)::int AS fertile,
count(*) FILTER (WHERE fertile_from_age <= puberty_start_age)::int AS invalid
FROM falukant_data.character`);
assert.ok(stats.starts > 1 && stats.fertile > 1);
assert.equal(stats.invalid, 0);
await assert.rejects(db.query(`INSERT INTO falukant_data.character
(puberty_start_age, fertile_from_age) VALUES (12, 11)`));
await t.test('daemon checks both parents and preserves already scheduled births', {
skip: !process.env.DAEMON_SQL_PATH,
}, async () => {
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]*?)"#;`));
assert.ok(match, name);
// Remove chance from these eligibility tests only.
return match[1].replaceAll('random()', '0.0');
};
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');
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.relationship_state (
relationship_id INT, active BOOLEAN, affection INT, maintenance_level INT,
last_monthly_processed_at TIMESTAMPTZ, lover_role TEXT, visibility INT
);
INSERT INTO falukant_data.character
(gender, birthdate, puberty_start_age, fertile_from_age)
VALUES ('female', CURRENT_DATE - INTERVAL '13 days', 10, 13),
('male', CURRENT_DATE - INTERVAL '13 days', 10, 13);
INSERT INTO falukant_data.relationship VALUES (1, 1, 2, 1, NULL);
INSERT INTO falukant_data.relationship_state VALUES (1, true, 80, 50, NOW(), 'lover', 20);
`);
for (const [queryName, type] of [
['QUERY_TRY_MARRIAGE_CONCEPTION_UPDATE', 1],
['QUERY_GET_LEGACY_MARRIAGE_INSTANT_PREGNANCY_CANDIDATES', 1],
['QUERY_GET_LOVER_PREGNANCY_CANDIDATES', 2],
]) {
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 -
(CASE WHEN id = 1 THEN :motherAge ELSE :fatherAge END) * INTERVAL '1 day',
pregnancy_due_at = NULL;
UPDATE falukant_data.relationship SET relationship_type_id = :type,
marriage_pregnancy_due_at = NULL`, {
replacements: { motherAge, fatherAge, type },
});
const [rows] = await db.query(sql(queryName));
if (queryName === 'QUERY_TRY_MARRIAGE_CONCEPTION_UPDATE') {
const [[relationship]] = await db.query('SELECT marriage_pregnancy_due_at FROM falukant_data.relationship');
assert.equal(relationship.marriage_pregnancy_due_at != null, eligible, `${queryName}: ${motherAge}/${fatherAge}`);
} else {
assert.equal(rows.length > 0, eligible, `${queryName}: ${motherAge}/${fatherAge}`);
}
}
}
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 -
(CASE WHEN id = 1 THEN :motherAge ELSE :fatherAge END) * INTERVAL '1 day',
pregnancy_due_at = NULL`, { replacements: { motherAge, fatherAge } });
const [rows] = await db.query(sql('QUERY_SET_CHARACTER_PREGNANCY_FROM_LOVER'), { bind: [1, 2] });
assert.equal(rows.length > 0, eligible);
}
// A backfilled threshold must not cancel an existing marriage pregnancy.
await db.query(`UPDATE falukant_data.character SET fertile_from_age = 17, pregnancy_due_at = NULL;
UPDATE falukant_data.relationship SET relationship_type_id = 1,
marriage_pregnancy_due_at = NOW() - INTERVAL '1 hour'`);
const [births] = await db.query(sql('QUERY_GET_MARRIAGE_BIRTH_DELIVERIES'));
assert.equal(births.length, 1);
});
await migration.down(qi);
const [[columns]] = await db.query(`SELECT count(*)::int AS count FROM information_schema.columns
WHERE table_schema = 'falukant_data' AND table_name = 'character'
AND column_name IN ('puberty_start_age', 'fertile_from_age')`);
assert.equal(columns.count, 0);
} finally {
await db.close();
}
});

View File

@@ -0,0 +1,17 @@
// Ages are whole game years (one real calendar day per year).
export function hasReachedFertility(character, age) {
const threshold = character?.fertileFromAge;
return Number.isInteger(threshold) && threshold > 0
&& Number.isFinite(age) && age >= threshold;
}
export function getCharacterDevelopment(character, age) {
const pubertyStartAge = character?.pubertyStartAge ?? null;
return {
pubertyStartAge,
fertileFromAge: character?.fertileFromAge ?? null,
hasReachedPuberty: Number.isInteger(pubertyStartAge) && pubertyStartAge > 0
&& Number.isFinite(age) && age >= pubertyStartAge,
hasReachedFertility: hasReachedFertility(character, age),
};
}

View File

@@ -1056,6 +1056,7 @@
} }
}, },
"spouse": { "spouse": {
"weddingAgeHint": "Mahimong magsaulog og kasal kon ang duha ka managtrato labing menos {age} ka tuig na sa dula.",
"traitsToggle": "Character traits", "traitsToggle": "Character traits",
"marriageSatisfaction": "Kasal Satisfaction", "marriageSatisfaction": "Kasal Satisfaction",
"marriageState": "Kasal State", "marriageState": "Kasal State",
@@ -1315,6 +1316,7 @@
"order": "Umara og party", "order": "Umara og party",
"inProgress": "Mga party nga giandam", "inProgress": "Mga party nga giandam",
"completed": "Nahuman nga mga party", "completed": "Nahuman nga mga party",
"weddingUnavailable": "Ang kasal nagkinahanglan og managtrato nga ang duha labing menos {age} ka tuig na sa dula.",
"newpartyview": { "newpartyview": {
"open": "Create New Party", "open": "Create New Party",
"close": "Hide New Party", "close": "Hide New Party",

View File

@@ -667,6 +667,7 @@
"dueHint": "Voraussichtlicher Geburtstermin" "dueHint": "Voraussichtlicher Geburtstermin"
}, },
"spouse": { "spouse": {
"weddingAgeHint": "Eine Hochzeitsfeier ist möglich, sobald beide Verlobten mindestens {age} Spieljahre alt sind.",
"title": "Beziehung", "title": "Beziehung",
"traitsToggle": "Charaktereigenschaften", "traitsToggle": "Charaktereigenschaften",
"name": "Name", "name": "Name",
@@ -1302,6 +1303,7 @@
"order": "Fest veranstalten", "order": "Fest veranstalten",
"inProgress": "Feste in Vorbereitung", "inProgress": "Feste in Vorbereitung",
"completed": "Abgeschlossene Feste", "completed": "Abgeschlossene Feste",
"weddingUnavailable": "Für eine Hochzeit muss ein verlobtes Paar vorhanden sein, bei dem beide mindestens {age} Spieljahre alt sind.",
"newpartyview": { "newpartyview": {
"open": "Neues Fest erstellen", "open": "Neues Fest erstellen",
"close": "Neues Fest verbergen", "close": "Neues Fest verbergen",

View File

@@ -937,6 +937,7 @@
} }
}, },
"spouse": { "spouse": {
"weddingAgeHint": "You can hold a wedding celebration once both partners are at least {age} game years old.",
"traitsToggle": "Character traits", "traitsToggle": "Character traits",
"marriageSatisfaction": "Marriage Satisfaction", "marriageSatisfaction": "Marriage Satisfaction",
"marriageState": "Marriage State", "marriageState": "Marriage State",
@@ -1282,6 +1283,7 @@
"order": "Order Party", "order": "Order Party",
"inProgress": "Parties in Preparation", "inProgress": "Parties in Preparation",
"completed": "Completed Parties", "completed": "Completed Parties",
"weddingUnavailable": "A wedding requires an engaged couple whose partners are both at least {age} game years old.",
"newpartyview": { "newpartyview": {
"open": "Create New Party", "open": "Create New Party",
"close": "Hide New Party", "close": "Hide New Party",

View File

@@ -655,6 +655,7 @@
"dueHint": "Fecha prevista de nacimiento" "dueHint": "Fecha prevista de nacimiento"
}, },
"spouse": { "spouse": {
"weddingAgeHint": "Puedes celebrar una boda cuando ambos prometidos tengan al menos {age} años de juego.",
"title": "Relación", "title": "Relación",
"traitsToggle": "Rasgos de carácter", "traitsToggle": "Rasgos de carácter",
"name": "Nombre", "name": "Nombre",
@@ -1276,6 +1277,7 @@
"order": "Organizar fiesta", "order": "Organizar fiesta",
"inProgress": "Fiestas en preparación", "inProgress": "Fiestas en preparación",
"completed": "Fiestas completadas", "completed": "Fiestas completadas",
"weddingUnavailable": "Una boda requiere una pareja comprometida en la que ambos tengan al menos {age} años de juego.",
"newpartyview": { "newpartyview": {
"open": "Crear nueva fiesta", "open": "Crear nueva fiesta",
"close": "Ocultar nueva fiesta", "close": "Ocultar nueva fiesta",

View File

@@ -659,6 +659,7 @@
"dueHint": "Date d'échéance prévue" "dueHint": "Date d'échéance prévue"
}, },
"spouse": { "spouse": {
"weddingAgeHint": "Une fête de mariage est possible dès que les deux fiancés ont au moins {age} ans dans le jeu.",
"title": "Relation", "title": "Relation",
"traitsToggle": "Traits de caractère", "traitsToggle": "Traits de caractère",
"name": "nom", "name": "nom",
@@ -1271,6 +1272,7 @@
"order": "Organiser un festival", "order": "Organiser un festival",
"inProgress": "Des festivals en préparation", "inProgress": "Des festivals en préparation",
"completed": "Festivals terminés", "completed": "Festivals terminés",
"weddingUnavailable": "Un mariage nécessite un couple fiancé dont les deux partenaires ont au moins {age} ans dans le jeu.",
"newpartyview": { "newpartyview": {
"open": "Créer un nouveau festival", "open": "Créer un nouveau festival",
"close": "Masquer le nouveau festival", "close": "Masquer le nouveau festival",

View File

@@ -132,8 +132,9 @@
</div> </div>
</dl> </dl>
<div v-if="relationships[0].relationshipType === 'engaged'" class="spouse-card__cta"> <div v-if="relationships[0].relationshipType === 'engaged'" class="spouse-card__cta">
<button type="button" class="button" @click="jumpToPartyForm">{{ $t('falukant.family.spouse.jumpToPartyForm') <button v-if="relationships[0].canCelebrateWedding" type="button" class="button" @click="jumpToPartyForm">{{ $t('falukant.family.spouse.jumpToPartyForm')
}}</button> }}</button>
<p v-else>{{ $t('falukant.family.spouse.weddingAgeHint', { age: minMarriageAge }) }}</p>
</div> </div>
<details <details
v-if="relationships[0].character2.traits?.length" v-if="relationships[0].character2.traits?.length"
@@ -683,6 +684,7 @@ export default {
data() { data() {
return { return {
relationships: [], relationships: [],
minMarriageAge: null,
children: [], children: [],
lovers: [], lovers: [],
politicalFreeLoverSlots: 0, politicalFreeLoverSlots: 0,
@@ -891,6 +893,7 @@ export default {
try { try {
const response = await apiClient.get('/api/falukant/family'); const response = await apiClient.get('/api/falukant/family');
this.relationships = response.data.relationships; this.relationships = response.data.relationships;
this.minMarriageAge = response.data.minMarriageAge;
this.children = response.data.children; this.children = response.data.children;
this.selectedChildProposalIds = Object.fromEntries( this.selectedChildProposalIds = Object.fromEntries(
this.children.map((child) => [child.childCharacterId, this.selectedChildProposalIds[child.childCharacterId] || null]) this.children.map((child) => [child.childCharacterId, this.selectedChildProposalIds[child.childCharacterId] || null])

View File

@@ -66,6 +66,9 @@
</button> </button>
<div v-if="newPartyView" class="new-party-form"> <div v-if="newPartyView" class="new-party-form">
<p v-if="weddingCandidates.length === 0 && minMarriageAge != null">
{{ $t('falukant.reputation.party.weddingUnavailable', { age: minMarriageAge }) }}
</p>
<label> <label>
{{ $t('falukant.reputation.party.newpartyview.type') }}: {{ $t('falukant.reputation.party.newpartyview.type') }}:
<select v-model.number="newPartyTypeId"> <select v-model.number="newPartyTypeId">
@@ -226,6 +229,7 @@ export default {
relationshipId: null, relationshipId: null,
isOrderingParty: false, isOrderingParty: false,
weddingCandidates: [], weddingCandidates: [],
minMarriageAge: null,
inProgressParties: [], inProgressParties: [],
completedParties: [], completedParties: [],
reputation: null, reputation: null,
@@ -248,6 +252,7 @@ export default {
this.musicTypes = data.musicTypes; this.musicTypes = data.musicTypes;
this.banquetteTypes = data.banquetteTypes; this.banquetteTypes = data.banquetteTypes;
this.weddingCandidates = data.weddingCandidates || []; this.weddingCandidates = data.weddingCandidates || [];
this.minMarriageAge = data.minMarriageAge;
this.musicId = this.musicTypes[0]?.id; this.musicId = this.musicTypes[0]?.id;
this.banquetteId = this.banquetteTypes[0]?.id; this.banquetteId = this.banquetteTypes[0]?.id;
}, },