Speichere individuelle Haar- und Augenfarben je Charakter
All checks were successful
Deploy to production / deploy (push) Successful in 2m24s
All checks were successful
Deploy to production / deploy (push) Successful in 2m24s
This commit is contained in:
38
backend/docs/falukant-character-appearance.md
Normal file
38
backend/docs/falukant-character-appearance.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Dauerhafte Charakterfarben
|
||||||
|
|
||||||
|
`falukant_data.character` enthält `hair_color` und `eye_color` als `VARCHAR(7)`.
|
||||||
|
Die Sequelize- und API-Felder heißen `hairColor` und `eyeColor`.
|
||||||
|
Erlaubt sind sechsstellige Hex-Farben einschließlich `#`, z. B. `#69432d`.
|
||||||
|
|
||||||
|
Migration: `20261104000001-add-character-appearance.cjs`.
|
||||||
|
Im Backend-Verzeichnis mit `npm run db:migrate` ausführen, bevor der neue
|
||||||
|
Backend-Code startet. Die Migration wird hier nur in einer isolierten
|
||||||
|
Testdatenbank ausgeführt; die produktive Datenbank wird nicht verändert.
|
||||||
|
|
||||||
|
Die Migration vergibt für bestehende Charaktere einmalig Farben aus einer
|
||||||
|
Palette mit sieben Haar- und sechs Augenfarben. Bereits vorhandene Werte
|
||||||
|
bleiben erhalten, auch beim erneuten Ausführen der Migration. Ein separater
|
||||||
|
DB-Trigger vergibt Farben für neue Charaktere, einschließlich roher INSERTs
|
||||||
|
des Rust-Daemons für NPCs und Neugeborene. NULL-Werte bei Updates behalten die
|
||||||
|
bisherige Farbe. Andere explizite, gültige Hex-Farben sind ebenfalls zulässig.
|
||||||
|
Die Farben ändern sich beim Altern nicht automatisch. Vererbung und Ergrauen
|
||||||
|
sind noch nicht implementiert; die aktuelle Palette ist eine Spielverteilung.
|
||||||
|
|
||||||
|
Familie (Partner, Kinder, Liebschaften), Taufliste, Direktoren sowie politische
|
||||||
|
und kirchliche Amtsträger liefern die Farben an die vorhandene 3D-Anzeige.
|
||||||
|
Die Spielerdaten und das Dashboard liefern sie ebenfalls. Die 3D-Komponenten
|
||||||
|
verwenden die gespeicherten Farben nur in den markierten Haar-/Irisflächen.
|
||||||
|
Unmarkierte Altersmodelle und die 2D-Porträts behalten ihre Originaltexturen.
|
||||||
|
Der Modell-Editor bleibt eine Vorschau ohne Schreibzugriff auf Charakterdaten.
|
||||||
|
|
||||||
|
Prüfung mit einer ausdrücklich bereitgestellten, wegwerfbaren Datenbank:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TEST_DATABASE_URL='postgresql://…/yourpart_development_test' \
|
||||||
|
node --test tools/characterAppearance.test.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
Der Integrationstest prüft Bestandsdaten, wiederholte und teilweise manuell
|
||||||
|
vorbereitete Migrationen, beide DB-Trigger zusammen, rohe INSERTs,
|
||||||
|
Sequelize-Erstellung/-Updates, Farbvalidierung und Rollback. Er verwirft
|
||||||
|
das Schema `falukant_data` ausschließlich in `yourpart_development_test`.
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(queryInterface) {
|
||||||
|
await queryInterface.sequelize.transaction(async (transaction) => {
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
ALTER TABLE falukant_data.character
|
||||||
|
ADD COLUMN IF NOT EXISTS hair_color VARCHAR(7),
|
||||||
|
ADD COLUMN IF NOT EXISTS eye_color VARCHAR(7);
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION falukant_data.initialize_character_appearance()
|
||||||
|
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||||
|
BEGIN
|
||||||
|
IF TG_OP = 'UPDATE' THEN
|
||||||
|
NEW.hair_color := COALESCE(NEW.hair_color, OLD.hair_color);
|
||||||
|
NEW.eye_color := COALESCE(NEW.eye_color, OLD.eye_color);
|
||||||
|
END IF;
|
||||||
|
IF NEW.hair_color IS NULL THEN
|
||||||
|
NEW.hair_color := (ARRAY[
|
||||||
|
'#211b18', '#493024', '#69432d', '#95603c', '#c69c60', '#d8bf83', '#a34826'
|
||||||
|
])[1 + floor(random() * 7)::int];
|
||||||
|
END IF;
|
||||||
|
IF NEW.eye_color IS NULL THEN
|
||||||
|
NEW.eye_color := (ARRAY[
|
||||||
|
'#493026', '#795532', '#8b7544', '#56704c', '#497c9c', '#75858b'
|
||||||
|
])[1 + floor(random() * 6)::int];
|
||||||
|
END IF;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS initialize_character_appearance ON falukant_data.character;
|
||||||
|
CREATE TRIGGER initialize_character_appearance
|
||||||
|
BEFORE INSERT OR UPDATE ON falukant_data.character
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION falukant_data.initialize_character_appearance();
|
||||||
|
|
||||||
|
UPDATE falukant_data.character SET hair_color = hair_color
|
||||||
|
WHERE hair_color IS NULL OR eye_color IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE falukant_data.character
|
||||||
|
ALTER COLUMN hair_color SET NOT NULL,
|
||||||
|
ALTER COLUMN eye_color SET NOT NULL;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint
|
||||||
|
WHERE conrelid = 'falukant_data.character'::regclass
|
||||||
|
AND conname = 'character_appearance_colors_valid'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE falukant_data.character
|
||||||
|
ADD CONSTRAINT character_appearance_colors_valid CHECK (
|
||||||
|
hair_color ~ '^#[0-9a-fA-F]{6}$'
|
||||||
|
AND eye_color ~ '^#[0-9a-fA-F]{6}$'
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
`, { transaction });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(queryInterface) {
|
||||||
|
await queryInterface.sequelize.transaction(async (transaction) => {
|
||||||
|
await queryInterface.sequelize.query(`
|
||||||
|
DROP TRIGGER IF EXISTS initialize_character_appearance ON falukant_data.character;
|
||||||
|
DROP FUNCTION IF EXISTS falukant_data.initialize_character_appearance();
|
||||||
|
ALTER TABLE falukant_data.character
|
||||||
|
DROP CONSTRAINT IF EXISTS character_appearance_colors_valid,
|
||||||
|
DROP COLUMN IF EXISTS hair_color,
|
||||||
|
DROP COLUMN IF EXISTS eye_color;
|
||||||
|
`, { transaction });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -23,6 +23,17 @@ FalukantCharacter.init(
|
|||||||
defaultValue: DataTypes.NOW},
|
defaultValue: DataTypes.NOW},
|
||||||
gender: {
|
gender: {
|
||||||
type: DataTypes.STRING},
|
type: DataTypes.STRING},
|
||||||
|
// The DB trigger initializes these for Sequelize and raw daemon inserts.
|
||||||
|
hairColor: {
|
||||||
|
type: DataTypes.STRING(7),
|
||||||
|
allowNull: true,
|
||||||
|
validate: { is: /^#[0-9a-fA-F]{6}$/ },
|
||||||
|
},
|
||||||
|
eyeColor: {
|
||||||
|
type: DataTypes.STRING(7),
|
||||||
|
allowNull: true,
|
||||||
|
validate: { is: /^#[0-9a-fA-F]{6}$/ },
|
||||||
|
},
|
||||||
// Initialized once by the DB trigger, also for raw daemon INSERTs.
|
// Initialized once by the DB trigger, also for raw daemon INSERTs.
|
||||||
// Nullable here so Sequelize lets omitted values reach that trigger;
|
// Nullable here so Sequelize lets omitted values reach that trigger;
|
||||||
// the database enforces NOT NULL and the ordering of both ages.
|
// the database enforces NOT NULL and the ordering of both ages.
|
||||||
|
|||||||
@@ -980,7 +980,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', 'pubertyStartAge', 'fertileFromAge']
|
attributes: ['id', 'birthdate', 'gender', 'moodId', 'health', 'reputation', 'pubertyStartAge', 'fertileFromAge', 'hairColor', 'eyeColor']
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
@@ -1028,7 +1028,7 @@ class FalukantService extends BaseService {
|
|||||||
{ model: FalukantPredefineLastname, as: 'definedLastName', attributes: ['name'] },
|
{ model: FalukantPredefineLastname, as: 'definedLastName', attributes: ['name'] },
|
||||||
{ model: TitleOfNobility, as: 'nobleTitle', attributes: ['labelTr', 'level'] }
|
{ model: TitleOfNobility, as: 'nobleTitle', attributes: ['labelTr', 'level'] }
|
||||||
],
|
],
|
||||||
attributes: ['id', 'birthdate', 'gender', 'reputation', 'titleOfNobility']
|
attributes: ['id', 'birthdate', 'gender', 'reputation', 'titleOfNobility', 'hairColor', 'eyeColor']
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: RegionData,
|
model: RegionData,
|
||||||
@@ -1142,7 +1142,7 @@ class FalukantService extends BaseService {
|
|||||||
{
|
{
|
||||||
model: FalukantCharacter,
|
model: FalukantCharacter,
|
||||||
as: 'character',
|
as: 'character',
|
||||||
attributes: ['id', 'birthdate', 'health', 'reputation', 'titleOfNobility', 'pubertyStartAge', 'fertileFromAge'],
|
attributes: ['id', 'birthdate', 'health', 'reputation', 'titleOfNobility', 'pubertyStartAge', 'fertileFromAge', 'hairColor', 'eyeColor'],
|
||||||
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'] },
|
||||||
@@ -2944,7 +2944,7 @@ class FalukantService extends BaseService {
|
|||||||
{
|
{
|
||||||
model: FalukantCharacter,
|
model: FalukantCharacter,
|
||||||
as: 'character',
|
as: 'character',
|
||||||
attributes: ['firstName', 'lastName', 'birthdate', 'titleOfNobility', 'gender'],
|
attributes: ['firstName', 'lastName', 'birthdate', 'titleOfNobility', 'gender', 'hairColor', 'eyeColor'],
|
||||||
where: { regionId },
|
where: { regionId },
|
||||||
include: [
|
include: [
|
||||||
{ model: FalukantPredefineFirstname, as: 'definedFirstName' },
|
{ model: FalukantPredefineFirstname, as: 'definedFirstName' },
|
||||||
@@ -3287,6 +3287,8 @@ class FalukantService extends BaseService {
|
|||||||
age,
|
age,
|
||||||
knowledge,
|
knowledge,
|
||||||
gender: proposal.character.gender,
|
gender: proposal.character.gender,
|
||||||
|
hairColor: proposal.character.hairColor ?? null,
|
||||||
|
eyeColor: proposal.character.eyeColor ?? null,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -3370,7 +3372,7 @@ class FalukantService extends BaseService {
|
|||||||
as: 'character',
|
as: 'character',
|
||||||
// Wichtige Felder explizit auswählen, damit die Joins auf Titel,
|
// Wichtige Felder explizit auswählen, damit die Joins auf Titel,
|
||||||
// Namen und Region im äußeren Select funktionieren.
|
// Namen und Region im äußeren Select funktionieren.
|
||||||
attributes: ['id', 'birthdate', 'gender', 'titleOfNobility', 'firstName', 'lastName', 'regionId'],
|
attributes: ['id', 'birthdate', 'gender', 'titleOfNobility', 'firstName', 'lastName', 'regionId', 'hairColor', 'eyeColor'],
|
||||||
where: {
|
where: {
|
||||||
regionId: branch.regionId,
|
regionId: branch.regionId,
|
||||||
},
|
},
|
||||||
@@ -3434,6 +3436,8 @@ class FalukantService extends BaseService {
|
|||||||
title: director.character.nobleTitle.labelTr,
|
title: director.character.nobleTitle.labelTr,
|
||||||
age,
|
age,
|
||||||
gender: director.character.gender,
|
gender: director.character.gender,
|
||||||
|
hairColor: director.character.hairColor ?? null,
|
||||||
|
eyeColor: director.character.eyeColor ?? null,
|
||||||
nobleTitle: director.character.nobleTitle,
|
nobleTitle: director.character.nobleTitle,
|
||||||
definedFirstName: director.character.definedFirstName,
|
definedFirstName: director.character.definedFirstName,
|
||||||
definedLastName: director.character.definedLastName,
|
definedLastName: director.character.definedLastName,
|
||||||
@@ -3463,7 +3467,7 @@ class FalukantService extends BaseService {
|
|||||||
{
|
{
|
||||||
model: FalukantCharacter,
|
model: FalukantCharacter,
|
||||||
as: 'character',
|
as: 'character',
|
||||||
attributes: ['id', 'birthdate', 'gender'],
|
attributes: ['id', 'birthdate', 'gender', 'hairColor', 'eyeColor'],
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
model: TitleOfNobility,
|
model: TitleOfNobility,
|
||||||
@@ -3632,7 +3636,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', 'pubertyStartAge', 'fertileFromAge'],
|
attributes: ['id', 'birthdate', 'gender', 'moodId', 'pubertyStartAge', 'fertileFromAge', 'hairColor', 'eyeColor'],
|
||||||
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'] },
|
||||||
@@ -3675,6 +3679,8 @@ class FalukantService extends BaseService {
|
|||||||
age: calcAge(c2.birthdate),
|
age: calcAge(c2.birthdate),
|
||||||
development: getCharacterDevelopment(c2, calcAge(c2.birthdate)),
|
development: getCharacterDevelopment(c2, calcAge(c2.birthdate)),
|
||||||
gender: c2.gender,
|
gender: c2.gender,
|
||||||
|
hairColor: c2.hairColor ?? null,
|
||||||
|
eyeColor: c2.eyeColor ?? null,
|
||||||
firstName: c2.definedFirstName?.name || 'Unknown',
|
firstName: c2.definedFirstName?.name || 'Unknown',
|
||||||
nobleTitle: c2.nobleTitle?.labelTr || '',
|
nobleTitle: c2.nobleTitle?.labelTr || '',
|
||||||
mood: c2.mood,
|
mood: c2.mood,
|
||||||
@@ -3720,7 +3726,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', 'pubertyStartAge', 'fertileFromAge'],
|
attributes: ['id', 'birthdate', 'gender', 'regionId', 'titleOfNobility', 'pubertyStartAge', 'fertileFromAge', 'hairColor', 'eyeColor'],
|
||||||
include: [{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }]
|
include: [{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] }]
|
||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
@@ -3781,6 +3787,8 @@ class FalukantService extends BaseService {
|
|||||||
childCharacterId: rel.childCharacterId,
|
childCharacterId: rel.childCharacterId,
|
||||||
name: kid?.definedFirstName?.name || 'Unknown',
|
name: kid?.definedFirstName?.name || 'Unknown',
|
||||||
gender: kid?.gender,
|
gender: kid?.gender,
|
||||||
|
hairColor: kid?.hairColor ?? null,
|
||||||
|
eyeColor: kid?.eyeColor ?? null,
|
||||||
age: kid?.birthdate ? calcAge(kid.birthdate) : null,
|
age: kid?.birthdate ? calcAge(kid.birthdate) : null,
|
||||||
development: getCharacterDevelopment(kid, kid?.birthdate ? calcAge(kid.birthdate) : null),
|
development: getCharacterDevelopment(kid, kid?.birthdate ? calcAge(kid.birthdate) : null),
|
||||||
hasName: rel.nameSet,
|
hasName: rel.nameSet,
|
||||||
@@ -3857,6 +3865,8 @@ class FalukantService extends BaseService {
|
|||||||
name: partner.firstName || 'Unknown',
|
name: partner.firstName || 'Unknown',
|
||||||
age: partner.age != null ? partner.age : null,
|
age: partner.age != null ? partner.age : null,
|
||||||
gender: partner.gender || null,
|
gender: partner.gender || null,
|
||||||
|
hairColor: partner.hairColor ?? null,
|
||||||
|
eyeColor: partner.eyeColor ?? null,
|
||||||
title: partner.nobleTitle || '',
|
title: partner.nobleTitle || '',
|
||||||
role: state.loverRole || 'lover',
|
role: state.loverRole || 'lover',
|
||||||
affection: state.affection,
|
affection: state.affection,
|
||||||
@@ -4451,7 +4461,7 @@ class FalukantService extends BaseService {
|
|||||||
const loadCandidates = async (where) => FalukantCharacter.findAll({
|
const loadCandidates = async (where) => FalukantCharacter.findAll({
|
||||||
where,
|
where,
|
||||||
include: includes,
|
include: includes,
|
||||||
attributes: ['id', 'birthdate', 'gender'],
|
attributes: ['id', 'birthdate', 'gender', 'hairColor', 'eyeColor'],
|
||||||
order: sequelize.random(),
|
order: sequelize.random(),
|
||||||
limit: 10
|
limit: 10
|
||||||
});
|
});
|
||||||
@@ -4522,7 +4532,7 @@ class FalukantService extends BaseService {
|
|||||||
{
|
{
|
||||||
model: FalukantCharacter,
|
model: FalukantCharacter,
|
||||||
as: 'proposedCharacter',
|
as: 'proposedCharacter',
|
||||||
attributes: ['id', 'firstName', 'lastName', 'gender', 'regionId', 'birthdate'],
|
attributes: ['id', 'firstName', 'lastName', 'gender', 'regionId', 'birthdate', 'hairColor', 'eyeColor'],
|
||||||
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'] },
|
||||||
@@ -5774,6 +5784,8 @@ class FalukantService extends BaseService {
|
|||||||
return {
|
return {
|
||||||
id: child.child.id,
|
id: child.child.id,
|
||||||
gender: child.child.gender,
|
gender: child.child.gender,
|
||||||
|
hairColor: child.child.hairColor ?? null,
|
||||||
|
eyeColor: child.child.eyeColor ?? null,
|
||||||
age: calcAge(child.child.birthdate),
|
age: calcAge(child.child.birthdate),
|
||||||
proposedFirstName: child.child.definedFirstName.name,
|
proposedFirstName: child.child.definedFirstName.name,
|
||||||
};
|
};
|
||||||
@@ -6824,7 +6836,7 @@ class FalukantService extends BaseService {
|
|||||||
{
|
{
|
||||||
model: FalukantCharacter,
|
model: FalukantCharacter,
|
||||||
as: 'holder',
|
as: 'holder',
|
||||||
attributes: ['id', 'gender', 'birthdate'],
|
attributes: ['id', 'gender', 'birthdate', 'hairColor', 'eyeColor'],
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
model: FalukantPredefineFirstname,
|
model: FalukantPredefineFirstname,
|
||||||
@@ -6906,6 +6918,8 @@ class FalukantService extends BaseService {
|
|||||||
definedLastName: o.holder.definedLastName,
|
definedLastName: o.holder.definedLastName,
|
||||||
nobleTitle: o.holder.nobleTitle,
|
nobleTitle: o.holder.nobleTitle,
|
||||||
gender: o.holder.gender,
|
gender: o.holder.gender,
|
||||||
|
hairColor: o.holder.hairColor ?? null,
|
||||||
|
eyeColor: o.holder.eyeColor ?? null,
|
||||||
age: o.holder.birthdate ? calcAge(o.holder.birthdate) : null
|
age: o.holder.birthdate ? calcAge(o.holder.birthdate) : null
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
@@ -6975,7 +6989,7 @@ class FalukantService extends BaseService {
|
|||||||
include: [{
|
include: [{
|
||||||
model: FalukantCharacter,
|
model: FalukantCharacter,
|
||||||
as: 'character',
|
as: 'character',
|
||||||
attributes: ['birthdate', 'gender'],
|
attributes: ['birthdate', 'gender', 'hairColor', 'eyeColor'],
|
||||||
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'] },
|
||||||
@@ -7890,7 +7904,7 @@ ORDER BY r.id`,
|
|||||||
{
|
{
|
||||||
model: FalukantCharacter,
|
model: FalukantCharacter,
|
||||||
as: 'character',
|
as: 'character',
|
||||||
attributes: ['id', 'birthdate', 'gender'],
|
attributes: ['id', 'birthdate', 'gender', 'hairColor', 'eyeColor'],
|
||||||
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'] },
|
||||||
@@ -7929,6 +7943,8 @@ ORDER BY r.id`,
|
|||||||
titleLabelTr: titleLabelTr || null,
|
titleLabelTr: titleLabelTr || null,
|
||||||
nameWithoutTitle,
|
nameWithoutTitle,
|
||||||
gender: character.gender ?? null,
|
gender: character.gender ?? null,
|
||||||
|
hairColor: character.hairColor ?? null,
|
||||||
|
eyeColor: character.eyeColor ?? null,
|
||||||
age,
|
age,
|
||||||
money: Number(falukantUser.money ?? 0),
|
money: Number(falukantUser.money ?? 0),
|
||||||
unreadNotificationsCount,
|
unreadNotificationsCount,
|
||||||
@@ -8404,7 +8420,7 @@ ORDER BY r.id`,
|
|||||||
{
|
{
|
||||||
model: FalukantCharacter,
|
model: FalukantCharacter,
|
||||||
as: 'holder',
|
as: 'holder',
|
||||||
attributes: ['id', 'gender', 'birthdate'],
|
attributes: ['id', 'gender', 'birthdate', 'hairColor', 'eyeColor'],
|
||||||
required: false,
|
required: false,
|
||||||
include: [
|
include: [
|
||||||
{
|
{
|
||||||
@@ -8470,6 +8486,8 @@ ORDER BY r.id`,
|
|||||||
id: o.holder.id,
|
id: o.holder.id,
|
||||||
name: `${o.holder.definedFirstName?.name || ''} ${o.holder.definedLastName?.name || ''}`.trim(),
|
name: `${o.holder.definedFirstName?.name || ''} ${o.holder.definedLastName?.name || ''}`.trim(),
|
||||||
gender: o.holder.gender,
|
gender: o.holder.gender,
|
||||||
|
hairColor: o.holder.hairColor ?? null,
|
||||||
|
eyeColor: o.holder.eyeColor ?? null,
|
||||||
title: o.holder.nobleTitle?.labelTr,
|
title: o.holder.nobleTitle?.labelTr,
|
||||||
age: o.holder.birthdate ? calcAge(o.holder.birthdate) : null
|
age: o.holder.birthdate ? calcAge(o.holder.birthdate) : null
|
||||||
}
|
}
|
||||||
|
|||||||
81
backend/tools/characterAppearance.test.mjs
Normal file
81
backend/tools/characterAppearance.test.mjs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { Sequelize, DataTypes } from 'sequelize';
|
||||||
|
import migration from '../migrations-active/20261104000001-add-character-appearance.cjs';
|
||||||
|
import developmentMigration from '../migrations-active/20261104000000-add-character-development-ages.cjs';
|
||||||
|
|
||||||
|
// Only the explicitly supplied disposable database may be changed.
|
||||||
|
test('appearance migration persists colors for existing characters, Sequelize and raw daemon inserts', {
|
||||||
|
skip: !process.env.TEST_DATABASE_URL,
|
||||||
|
}, async () => {
|
||||||
|
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;
|
||||||
|
CREATE SCHEMA falukant_data;
|
||||||
|
CREATE TABLE falukant_data.character (
|
||||||
|
id SERIAL PRIMARY KEY, gender TEXT, health INT DEFAULT 100,
|
||||||
|
hair_color VARCHAR(7), updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
INSERT INTO falukant_data.character (gender, hair_color)
|
||||||
|
VALUES ('female', '#AbC123'), ('male', NULL);
|
||||||
|
`);
|
||||||
|
const qi = db.getQueryInterface();
|
||||||
|
await developmentMigration.up(qi);
|
||||||
|
const [developmentBefore] = await db.query('SELECT id, health, puberty_start_age, fertile_from_age, updated_at FROM falukant_data.character ORDER BY id');
|
||||||
|
await migration.up(qi);
|
||||||
|
const [developmentAfter] = await db.query('SELECT id, health, puberty_start_age, fertile_from_age, updated_at FROM falukant_data.character ORDER BY id');
|
||||||
|
assert.deepEqual(developmentAfter, developmentBefore, 'Existing character development remains intact');
|
||||||
|
const [before] = await db.query('SELECT * FROM falukant_data.character ORDER BY id');
|
||||||
|
assert.equal(before[0].hair_color, '#AbC123', 'Keep manually prepared values');
|
||||||
|
for (const row of before) {
|
||||||
|
assert.match(row.hair_color, /^#[0-9a-fA-F]{6}$/);
|
||||||
|
assert.match(row.eye_color, /^#[0-9a-fA-F]{6}$/);
|
||||||
|
}
|
||||||
|
await migration.up(qi);
|
||||||
|
await db.query(`UPDATE falukant_data.character SET hair_color = NULL, eye_color = NULL`);
|
||||||
|
const [after] = await db.query('SELECT * FROM falukant_data.character ORDER BY id');
|
||||||
|
assert.deepEqual(after, before, 'Repeated migrations and omitted colors keep existing assignments');
|
||||||
|
|
||||||
|
// NPCs and newborns created with raw Rust SQL also get stable colors.
|
||||||
|
const [generated] = 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 RETURNING hair_color, eye_color`);
|
||||||
|
assert.equal(new Set(generated.map(c => c.hair_color)).size, 7);
|
||||||
|
assert.equal(new Set(generated.map(c => c.eye_color)).size, 6);
|
||||||
|
assert.ok(generated.every(c => /^#[0-9a-fA-F]{6}$/.test(c.hair_color) && /^#[0-9a-fA-F]{6}$/.test(c.eye_color)));
|
||||||
|
|
||||||
|
const Character = db.define('AppearanceTestCharacter', {
|
||||||
|
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
|
||||||
|
gender: DataTypes.STRING,
|
||||||
|
hairColor: { type: DataTypes.STRING(7), field: 'hair_color' },
|
||||||
|
eyeColor: { type: DataTypes.STRING(7), field: 'eye_color' },
|
||||||
|
}, { schema: 'falukant_data', tableName: 'character', timestamps: false });
|
||||||
|
const character = await Character.create({ gender: 'female', eyeColor: '#123456' });
|
||||||
|
assert.match(character.hairColor, /^#[0-9a-fA-F]{6}$/);
|
||||||
|
assert.equal(character.eyeColor, '#123456');
|
||||||
|
const assigned = character.hairColor;
|
||||||
|
await character.update({ gender: 'male' });
|
||||||
|
await character.reload();
|
||||||
|
assert.equal(character.hairColor, assigned);
|
||||||
|
await character.update({ hairColor: '#654321' });
|
||||||
|
await character.reload();
|
||||||
|
assert.equal(character.hairColor, '#654321');
|
||||||
|
assert.equal(character.eyeColor, '#123456');
|
||||||
|
for (const color of ['red', '#fff', '#12345z', '']) {
|
||||||
|
await assert.rejects(db.query('UPDATE falukant_data.character SET eye_color = :color WHERE id = 1', {
|
||||||
|
replacements: { color },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const [[{ count: oldCount }]] = await db.query('SELECT count(*)::int FROM falukant_data.character');
|
||||||
|
await migration.down(qi);
|
||||||
|
const [[{ count: newCount }]] = await db.query('SELECT count(*)::int FROM falukant_data.character');
|
||||||
|
assert.equal(newCount, oldCount, 'Rollback retains characters');
|
||||||
|
await migration.up(qi);
|
||||||
|
} finally {
|
||||||
|
await db.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -42,7 +42,12 @@ da solche Dateien vom Quellmodell-Build übersprungen werden.
|
|||||||
|
|
||||||
Unmarkierte Modelle behalten ihr bisheriges Aussehen. Die vollständige
|
Unmarkierte Modelle behalten ihr bisheriges Aussehen. Die vollständige
|
||||||
Markierung aller Modellvarianten ist eine einmalige Aufgabe je Modell. Es gibt
|
Markierung aller Modellvarianten ist eine einmalige Aufgabe je Modell. Es gibt
|
||||||
keine neue Datenbankmigration oder Speicherung pro Spielcharakter.
|
keine separate Speicherung der Markierungen pro Spielcharakter.
|
||||||
|
|
||||||
|
Die Charakterfarben selbst werden inzwischen als `hairColor` und `eyeColor`
|
||||||
|
in den Charakterdaten gespeichert und an die Spielansichten weitergegeben.
|
||||||
|
Siehe `backend/docs/falukant-character-appearance.md` für Migration und
|
||||||
|
Initialisierung. Der Editor ändert weiterhin nur Modelle und Vorschaufarben.
|
||||||
|
|
||||||
## Schnittstelle für die Anzeige
|
## Schnittstelle für die Anzeige
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
<div class="director-main">
|
<div class="director-main">
|
||||||
<div class="director-visual" v-if="director.character">
|
<div class="director-visual" v-if="director.character">
|
||||||
<FalukantCharacterVisual
|
<FalukantCharacterVisual
|
||||||
|
:appearance="director.character"
|
||||||
:gender="director.character.gender"
|
:gender="director.character.gender"
|
||||||
:age="director.character.age"
|
:age="director.character.age"
|
||||||
:visual-settings="visualSettings"
|
:visual-settings="visualSettings"
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
<td>
|
<td>
|
||||||
<div class="church-person-cell">
|
<div class="church-person-cell">
|
||||||
<FalukantCharacterVisual
|
<FalukantCharacterVisual
|
||||||
|
:appearance="person"
|
||||||
:gender="person.gender"
|
:gender="person.gender"
|
||||||
:age="person.age"
|
:age="person.age"
|
||||||
:visual-settings="visualSettings"
|
:visual-settings="visualSettings"
|
||||||
@@ -91,6 +92,7 @@
|
|||||||
<span v-if="pos.character">
|
<span v-if="pos.character">
|
||||||
<div class="church-person-cell">
|
<div class="church-person-cell">
|
||||||
<FalukantCharacterVisual
|
<FalukantCharacterVisual
|
||||||
|
:appearance="pos.character"
|
||||||
:gender="pos.character.gender"
|
:gender="pos.character.gender"
|
||||||
:age="pos.character.age"
|
:age="pos.character.age"
|
||||||
:visual-settings="visualSettings"
|
:visual-settings="visualSettings"
|
||||||
|
|||||||
@@ -156,6 +156,7 @@
|
|||||||
></div>
|
></div>
|
||||||
<div v-if="showCharacterFigure3d" class="character-3d-frame character-3d-frame--spouse">
|
<div v-if="showCharacterFigure3d" class="character-3d-frame character-3d-frame--spouse">
|
||||||
<Character3D
|
<Character3D
|
||||||
|
:appearance="relationships[0].character2"
|
||||||
:gender="relationships[0].character2.gender"
|
:gender="relationships[0].character2.gender"
|
||||||
:age="relationships[0].character2.age"
|
:age="relationships[0].character2.age"
|
||||||
/>
|
/>
|
||||||
@@ -341,6 +342,7 @@
|
|||||||
:style="getChild3dBackgroundStyle(child)"
|
:style="getChild3dBackgroundStyle(child)"
|
||||||
>
|
>
|
||||||
<Character3D
|
<Character3D
|
||||||
|
:appearance="child"
|
||||||
:gender="child.gender"
|
:gender="child.gender"
|
||||||
:age="child.age"
|
:age="child.age"
|
||||||
:lightweight="true"
|
:lightweight="true"
|
||||||
@@ -452,6 +454,7 @@
|
|||||||
></div>
|
></div>
|
||||||
<div v-if="showCharacterFigure3d" class="character-3d-frame character-3d-frame--compact">
|
<div v-if="showCharacterFigure3d" class="character-3d-frame character-3d-frame--compact">
|
||||||
<Character3D
|
<Character3D
|
||||||
|
:appearance="lover"
|
||||||
:gender="lover.gender"
|
:gender="lover.gender"
|
||||||
:age="lover.age"
|
:age="lover.age"
|
||||||
:lightweight="true"
|
:lightweight="true"
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
<div class="politics-card__meta">
|
<div class="politics-card__meta">
|
||||||
<div v-if="pos.character" class="politics-card__visual">
|
<div v-if="pos.character" class="politics-card__visual">
|
||||||
<FalukantCharacterVisual
|
<FalukantCharacterVisual
|
||||||
|
:appearance="pos.character"
|
||||||
:gender="pos.character.gender"
|
:gender="pos.character.gender"
|
||||||
:age="pos.character.age"
|
:age="pos.character.age"
|
||||||
:visual-settings="visualSettings"
|
:visual-settings="visualSettings"
|
||||||
|
|||||||
Reference in New Issue
Block a user