Ergänze individuelle Charakterentwicklung und klare Hochzeitshinweise
Some checks failed
Deploy to production / deploy (push) Failing after 1m18s
Some checks failed
Deploy to production / deploy (push) Failing after 1m18s
This commit is contained in:
74
backend/docs/falukant-character-development.md
Normal file
74
backend/docs/falukant-character-development.md
Normal 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.
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -23,6 +23,17 @@ FalukantCharacter.init(
|
||||
defaultValue: DataTypes.NOW},
|
||||
gender: {
|
||||
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: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 RegionData from '../models/falukant/data/region.js';
|
||||
import RegionType from '../models/falukant/type/region.js';
|
||||
import FalukantStock from '../models/falukant/data/stock.js';
|
||||
@@ -96,7 +97,6 @@ function calcAge(birthdate) {
|
||||
const FAMILY_AGE = Object.freeze({
|
||||
MIN_WOOING: 10,
|
||||
MIN_MARRIAGE: 14,
|
||||
MIN_HOUSEHOLD_AND_CHILDREN: 14,
|
||||
});
|
||||
|
||||
async function getFalukantUserOrFail(hashedId) {
|
||||
@@ -979,7 +979,7 @@ class FalukantService extends BaseService {
|
||||
{ model: TitleOfNobility, as: 'nobleTitle', attributes: ['labelTr', 'id', 'level'] }
|
||||
],
|
||||
// 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,
|
||||
as: 'character',
|
||||
attributes: ['id', 'birthdate', 'health', 'reputation', 'titleOfNobility'],
|
||||
attributes: ['id', 'birthdate', 'health', 'reputation', 'titleOfNobility', 'pubertyStartAge', 'fertileFromAge'],
|
||||
include: [
|
||||
{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] },
|
||||
{ model: FalukantPredefineLastname, as: 'definedLastName', attributes: ['name'] },
|
||||
@@ -3649,11 +3649,11 @@ class FalukantService extends BaseService {
|
||||
|
||||
const parents = await FalukantCharacter.unscoped().findAll({
|
||||
where: { id: [motherId, fatherId] },
|
||||
attributes: ['id', 'birthdate'],
|
||||
attributes: ['id', 'birthdate', 'fertileFromAge'],
|
||||
});
|
||||
if (
|
||||
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 };
|
||||
}
|
||||
@@ -3733,7 +3733,7 @@ class FalukantService extends BaseService {
|
||||
RelationshipType.findAll({ where: { id: typeIds }, attributes: ['id', 'tr'] }),
|
||||
FalukantCharacter.findAll({
|
||||
where: { id: char2Ids },
|
||||
attributes: ['id', 'birthdate', 'gender', 'moodId'],
|
||||
attributes: ['id', 'birthdate', 'gender', 'moodId', 'pubertyStartAge', 'fertileFromAge'],
|
||||
include: [
|
||||
{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] },
|
||||
{ model: TitleOfNobility, as: 'nobleTitle', attributes: ['labelTr'] },
|
||||
@@ -3774,6 +3774,7 @@ class FalukantService extends BaseService {
|
||||
character2: c2 ? {
|
||||
id: c2.id,
|
||||
age: calcAge(c2.birthdate),
|
||||
development: getCharacterDevelopment(c2, calcAge(c2.birthdate)),
|
||||
gender: c2.gender,
|
||||
firstName: c2.definedFirstName?.name || 'Unknown',
|
||||
nobleTitle: c2.nobleTitle?.labelTr || '',
|
||||
@@ -3820,7 +3821,7 @@ class FalukantService extends BaseService {
|
||||
const childChars = childCharIds.length
|
||||
? await FalukantCharacter.findAll({
|
||||
where: { id: childCharIds },
|
||||
attributes: ['id', 'birthdate', 'gender', 'regionId', 'titleOfNobility'],
|
||||
attributes: ['id', 'birthdate', 'gender', 'regionId', 'titleOfNobility', 'pubertyStartAge', 'fertileFromAge'],
|
||||
include: [{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }]
|
||||
})
|
||||
: [];
|
||||
@@ -3882,6 +3883,7 @@ class FalukantService extends BaseService {
|
||||
name: kid?.definedFirstName?.name || 'Unknown',
|
||||
gender: kid?.gender,
|
||||
age: kid?.birthdate ? calcAge(kid.birthdate) : null,
|
||||
development: getCharacterDevelopment(kid, kid?.birthdate ? calcAge(kid.birthdate) : null),
|
||||
hasName: rel.nameSet,
|
||||
isHeir: rel.isHeir || false,
|
||||
legitimacy: rel.legitimacy || 'legitimate',
|
||||
@@ -3998,8 +4000,13 @@ class FalukantService extends BaseService {
|
||||
label: baseTension.label
|
||||
};
|
||||
const family = {
|
||||
development: getCharacterDevelopment(character, calcAge(character.birthdate)),
|
||||
minMarriageAge: FAMILY_AGE.MIN_MARRIAGE,
|
||||
relationships: activeRelationships.map((r) => ({
|
||||
...r,
|
||||
canCelebrateWedding: r.relationshipType === 'engaged'
|
||||
&& calcAge(character.birthdate) >= FAMILY_AGE.MIN_MARRIAGE
|
||||
&& r.character2?.age >= FAMILY_AGE.MIN_MARRIAGE,
|
||||
marriageSatisfaction: r.relationshipType === 'married'
|
||||
? (r.state?.marriageSatisfaction ?? 100)
|
||||
: null,
|
||||
@@ -5739,7 +5746,7 @@ class FalukantService extends BaseService {
|
||||
});
|
||||
const musicTypes = await MusicType.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) {
|
||||
|
||||
150
backend/tools/characterDevelopment.test.mjs
Normal file
150
backend/tools/characterDevelopment.test.mjs
Normal 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();
|
||||
}
|
||||
});
|
||||
17
backend/utils/falukant/characterDevelopment.js
Normal file
17
backend/utils/falukant/characterDevelopment.js
Normal 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),
|
||||
};
|
||||
}
|
||||
@@ -1056,6 +1056,7 @@
|
||||
}
|
||||
},
|
||||
"spouse": {
|
||||
"weddingAgeHint": "Mahimong magsaulog og kasal kon ang duha ka managtrato labing menos {age} ka tuig na sa dula.",
|
||||
"traitsToggle": "Character traits",
|
||||
"marriageSatisfaction": "Kasal Satisfaction",
|
||||
"marriageState": "Kasal State",
|
||||
@@ -1315,6 +1316,7 @@
|
||||
"order": "Umara og party",
|
||||
"inProgress": "Mga party nga giandam",
|
||||
"completed": "Nahuman nga mga party",
|
||||
"weddingUnavailable": "Ang kasal nagkinahanglan og managtrato nga ang duha labing menos {age} ka tuig na sa dula.",
|
||||
"newpartyview": {
|
||||
"open": "Create New Party",
|
||||
"close": "Hide New Party",
|
||||
|
||||
@@ -667,6 +667,7 @@
|
||||
"dueHint": "Voraussichtlicher Geburtstermin"
|
||||
},
|
||||
"spouse": {
|
||||
"weddingAgeHint": "Eine Hochzeitsfeier ist möglich, sobald beide Verlobten mindestens {age} Spieljahre alt sind.",
|
||||
"title": "Beziehung",
|
||||
"traitsToggle": "Charaktereigenschaften",
|
||||
"name": "Name",
|
||||
@@ -1302,6 +1303,7 @@
|
||||
"order": "Fest veranstalten",
|
||||
"inProgress": "Feste in Vorbereitung",
|
||||
"completed": "Abgeschlossene Feste",
|
||||
"weddingUnavailable": "Für eine Hochzeit muss ein verlobtes Paar vorhanden sein, bei dem beide mindestens {age} Spieljahre alt sind.",
|
||||
"newpartyview": {
|
||||
"open": "Neues Fest erstellen",
|
||||
"close": "Neues Fest verbergen",
|
||||
|
||||
@@ -937,6 +937,7 @@
|
||||
}
|
||||
},
|
||||
"spouse": {
|
||||
"weddingAgeHint": "You can hold a wedding celebration once both partners are at least {age} game years old.",
|
||||
"traitsToggle": "Character traits",
|
||||
"marriageSatisfaction": "Marriage Satisfaction",
|
||||
"marriageState": "Marriage State",
|
||||
@@ -1282,6 +1283,7 @@
|
||||
"order": "Order Party",
|
||||
"inProgress": "Parties in Preparation",
|
||||
"completed": "Completed Parties",
|
||||
"weddingUnavailable": "A wedding requires an engaged couple whose partners are both at least {age} game years old.",
|
||||
"newpartyview": {
|
||||
"open": "Create New Party",
|
||||
"close": "Hide New Party",
|
||||
|
||||
@@ -655,6 +655,7 @@
|
||||
"dueHint": "Fecha prevista de nacimiento"
|
||||
},
|
||||
"spouse": {
|
||||
"weddingAgeHint": "Puedes celebrar una boda cuando ambos prometidos tengan al menos {age} años de juego.",
|
||||
"title": "Relación",
|
||||
"traitsToggle": "Rasgos de carácter",
|
||||
"name": "Nombre",
|
||||
@@ -1276,6 +1277,7 @@
|
||||
"order": "Organizar fiesta",
|
||||
"inProgress": "Fiestas en preparación",
|
||||
"completed": "Fiestas completadas",
|
||||
"weddingUnavailable": "Una boda requiere una pareja comprometida en la que ambos tengan al menos {age} años de juego.",
|
||||
"newpartyview": {
|
||||
"open": "Crear nueva fiesta",
|
||||
"close": "Ocultar nueva fiesta",
|
||||
|
||||
@@ -659,6 +659,7 @@
|
||||
"dueHint": "Date d'échéance prévue"
|
||||
},
|
||||
"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",
|
||||
"traitsToggle": "Traits de caractère",
|
||||
"name": "nom",
|
||||
@@ -1271,6 +1272,7 @@
|
||||
"order": "Organiser un festival",
|
||||
"inProgress": "Des festivals en préparation",
|
||||
"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": {
|
||||
"open": "Créer un nouveau festival",
|
||||
"close": "Masquer le nouveau festival",
|
||||
|
||||
@@ -132,8 +132,9 @@
|
||||
</div>
|
||||
</dl>
|
||||
<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>
|
||||
<p v-else>{{ $t('falukant.family.spouse.weddingAgeHint', { age: minMarriageAge }) }}</p>
|
||||
</div>
|
||||
<details
|
||||
v-if="relationships[0].character2.traits?.length"
|
||||
@@ -683,6 +684,7 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
relationships: [],
|
||||
minMarriageAge: null,
|
||||
children: [],
|
||||
lovers: [],
|
||||
politicalFreeLoverSlots: 0,
|
||||
@@ -891,6 +893,7 @@ export default {
|
||||
try {
|
||||
const response = await apiClient.get('/api/falukant/family');
|
||||
this.relationships = response.data.relationships;
|
||||
this.minMarriageAge = response.data.minMarriageAge;
|
||||
this.children = response.data.children;
|
||||
this.selectedChildProposalIds = Object.fromEntries(
|
||||
this.children.map((child) => [child.childCharacterId, this.selectedChildProposalIds[child.childCharacterId] || null])
|
||||
|
||||
@@ -66,6 +66,9 @@
|
||||
</button>
|
||||
|
||||
<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>
|
||||
{{ $t('falukant.reputation.party.newpartyview.type') }}:
|
||||
<select v-model.number="newPartyTypeId">
|
||||
@@ -226,6 +229,7 @@ export default {
|
||||
relationshipId: null,
|
||||
isOrderingParty: false,
|
||||
weddingCandidates: [],
|
||||
minMarriageAge: null,
|
||||
inProgressParties: [],
|
||||
completedParties: [],
|
||||
reputation: null,
|
||||
@@ -248,6 +252,7 @@ export default {
|
||||
this.musicTypes = data.musicTypes;
|
||||
this.banquetteTypes = data.banquetteTypes;
|
||||
this.weddingCandidates = data.weddingCandidates || [];
|
||||
this.minMarriageAge = data.minMarriageAge;
|
||||
this.musicId = this.musicTypes[0]?.id;
|
||||
this.banquetteId = this.banquetteTypes[0]?.id;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user