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},
|
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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
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": {
|
"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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user