Speichere individuelle Haar- und Augenfarben je Charakter
All checks were successful
Deploy to production / deploy (push) Successful in 2m24s

This commit is contained in:
Torsten Schulz (local)
2026-11-04 16:54:33 +01:00
parent dd409a6a6a
commit bc7a0cb67a
10 changed files with 250 additions and 15 deletions

View 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`.

View File

@@ -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 });
});
},
};

View File

@@ -23,6 +23,17 @@ FalukantCharacter.init(
defaultValue: DataTypes.NOW},
gender: {
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.
// Nullable here so Sequelize lets omitted values reach that trigger;
// the database enforces NOT NULL and the ordering of both ages.

View File

@@ -980,7 +980,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', '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: TitleOfNobility, as: 'nobleTitle', attributes: ['labelTr', 'level'] }
],
attributes: ['id', 'birthdate', 'gender', 'reputation', 'titleOfNobility']
attributes: ['id', 'birthdate', 'gender', 'reputation', 'titleOfNobility', 'hairColor', 'eyeColor']
},
{
model: RegionData,
@@ -1142,7 +1142,7 @@ class FalukantService extends BaseService {
{
model: FalukantCharacter,
as: 'character',
attributes: ['id', 'birthdate', 'health', 'reputation', 'titleOfNobility', 'pubertyStartAge', 'fertileFromAge'],
attributes: ['id', 'birthdate', 'health', 'reputation', 'titleOfNobility', 'pubertyStartAge', 'fertileFromAge', 'hairColor', 'eyeColor'],
include: [
{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] },
{ model: FalukantPredefineLastname, as: 'definedLastName', attributes: ['name'] },
@@ -2944,7 +2944,7 @@ class FalukantService extends BaseService {
{
model: FalukantCharacter,
as: 'character',
attributes: ['firstName', 'lastName', 'birthdate', 'titleOfNobility', 'gender'],
attributes: ['firstName', 'lastName', 'birthdate', 'titleOfNobility', 'gender', 'hairColor', 'eyeColor'],
where: { regionId },
include: [
{ model: FalukantPredefineFirstname, as: 'definedFirstName' },
@@ -3287,6 +3287,8 @@ class FalukantService extends BaseService {
age,
knowledge,
gender: proposal.character.gender,
hairColor: proposal.character.hairColor ?? null,
eyeColor: proposal.character.eyeColor ?? null,
},
};
});
@@ -3370,7 +3372,7 @@ class FalukantService extends BaseService {
as: 'character',
// Wichtige Felder explizit auswählen, damit die Joins auf Titel,
// 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: {
regionId: branch.regionId,
},
@@ -3434,6 +3436,8 @@ class FalukantService extends BaseService {
title: director.character.nobleTitle.labelTr,
age,
gender: director.character.gender,
hairColor: director.character.hairColor ?? null,
eyeColor: director.character.eyeColor ?? null,
nobleTitle: director.character.nobleTitle,
definedFirstName: director.character.definedFirstName,
definedLastName: director.character.definedLastName,
@@ -3463,7 +3467,7 @@ class FalukantService extends BaseService {
{
model: FalukantCharacter,
as: 'character',
attributes: ['id', 'birthdate', 'gender'],
attributes: ['id', 'birthdate', 'gender', 'hairColor', 'eyeColor'],
include: [
{
model: TitleOfNobility,
@@ -3632,7 +3636,7 @@ class FalukantService extends BaseService {
RelationshipType.findAll({ where: { id: typeIds }, attributes: ['id', 'tr'] }),
FalukantCharacter.findAll({
where: { id: char2Ids },
attributes: ['id', 'birthdate', 'gender', 'moodId', 'pubertyStartAge', 'fertileFromAge'],
attributes: ['id', 'birthdate', 'gender', 'moodId', 'pubertyStartAge', 'fertileFromAge', 'hairColor', 'eyeColor'],
include: [
{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] },
{ model: TitleOfNobility, as: 'nobleTitle', attributes: ['labelTr'] },
@@ -3675,6 +3679,8 @@ class FalukantService extends BaseService {
age: calcAge(c2.birthdate),
development: getCharacterDevelopment(c2, calcAge(c2.birthdate)),
gender: c2.gender,
hairColor: c2.hairColor ?? null,
eyeColor: c2.eyeColor ?? null,
firstName: c2.definedFirstName?.name || 'Unknown',
nobleTitle: c2.nobleTitle?.labelTr || '',
mood: c2.mood,
@@ -3720,7 +3726,7 @@ class FalukantService extends BaseService {
const childChars = childCharIds.length
? await FalukantCharacter.findAll({
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'] }]
})
: [];
@@ -3781,6 +3787,8 @@ class FalukantService extends BaseService {
childCharacterId: rel.childCharacterId,
name: kid?.definedFirstName?.name || 'Unknown',
gender: kid?.gender,
hairColor: kid?.hairColor ?? null,
eyeColor: kid?.eyeColor ?? null,
age: kid?.birthdate ? calcAge(kid.birthdate) : null,
development: getCharacterDevelopment(kid, kid?.birthdate ? calcAge(kid.birthdate) : null),
hasName: rel.nameSet,
@@ -3857,6 +3865,8 @@ class FalukantService extends BaseService {
name: partner.firstName || 'Unknown',
age: partner.age != null ? partner.age : null,
gender: partner.gender || null,
hairColor: partner.hairColor ?? null,
eyeColor: partner.eyeColor ?? null,
title: partner.nobleTitle || '',
role: state.loverRole || 'lover',
affection: state.affection,
@@ -4451,7 +4461,7 @@ class FalukantService extends BaseService {
const loadCandidates = async (where) => FalukantCharacter.findAll({
where,
include: includes,
attributes: ['id', 'birthdate', 'gender'],
attributes: ['id', 'birthdate', 'gender', 'hairColor', 'eyeColor'],
order: sequelize.random(),
limit: 10
});
@@ -4522,7 +4532,7 @@ class FalukantService extends BaseService {
{
model: FalukantCharacter,
as: 'proposedCharacter',
attributes: ['id', 'firstName', 'lastName', 'gender', 'regionId', 'birthdate'],
attributes: ['id', 'firstName', 'lastName', 'gender', 'regionId', 'birthdate', 'hairColor', 'eyeColor'],
include: [
{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] },
{ model: FalukantPredefineLastname, as: 'definedLastName', attributes: ['name'] },
@@ -5774,6 +5784,8 @@ class FalukantService extends BaseService {
return {
id: child.child.id,
gender: child.child.gender,
hairColor: child.child.hairColor ?? null,
eyeColor: child.child.eyeColor ?? null,
age: calcAge(child.child.birthdate),
proposedFirstName: child.child.definedFirstName.name,
};
@@ -6824,7 +6836,7 @@ class FalukantService extends BaseService {
{
model: FalukantCharacter,
as: 'holder',
attributes: ['id', 'gender', 'birthdate'],
attributes: ['id', 'gender', 'birthdate', 'hairColor', 'eyeColor'],
include: [
{
model: FalukantPredefineFirstname,
@@ -6906,6 +6918,8 @@ class FalukantService extends BaseService {
definedLastName: o.holder.definedLastName,
nobleTitle: o.holder.nobleTitle,
gender: o.holder.gender,
hairColor: o.holder.hairColor ?? null,
eyeColor: o.holder.eyeColor ?? null,
age: o.holder.birthdate ? calcAge(o.holder.birthdate) : null
}
: null,
@@ -6975,7 +6989,7 @@ class FalukantService extends BaseService {
include: [{
model: FalukantCharacter,
as: 'character',
attributes: ['birthdate', 'gender'],
attributes: ['birthdate', 'gender', 'hairColor', 'eyeColor'],
include: [
{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] },
{ model: FalukantPredefineLastname, as: 'definedLastName', attributes: ['name'] },
@@ -7890,7 +7904,7 @@ ORDER BY r.id`,
{
model: FalukantCharacter,
as: 'character',
attributes: ['id', 'birthdate', 'gender'],
attributes: ['id', 'birthdate', 'gender', 'hairColor', 'eyeColor'],
include: [
{ model: FalukantPredefineFirstname, as: 'definedFirstName', attributes: ['name'] },
{ model: FalukantPredefineLastname, as: 'definedLastName', attributes: ['name'] },
@@ -7929,6 +7943,8 @@ ORDER BY r.id`,
titleLabelTr: titleLabelTr || null,
nameWithoutTitle,
gender: character.gender ?? null,
hairColor: character.hairColor ?? null,
eyeColor: character.eyeColor ?? null,
age,
money: Number(falukantUser.money ?? 0),
unreadNotificationsCount,
@@ -8404,7 +8420,7 @@ ORDER BY r.id`,
{
model: FalukantCharacter,
as: 'holder',
attributes: ['id', 'gender', 'birthdate'],
attributes: ['id', 'gender', 'birthdate', 'hairColor', 'eyeColor'],
required: false,
include: [
{
@@ -8470,6 +8486,8 @@ ORDER BY r.id`,
id: o.holder.id,
name: `${o.holder.definedFirstName?.name || ''} ${o.holder.definedLastName?.name || ''}`.trim(),
gender: o.holder.gender,
hairColor: o.holder.hairColor ?? null,
eyeColor: o.holder.eyeColor ?? null,
title: o.holder.nobleTitle?.labelTr,
age: o.holder.birthdate ? calcAge(o.holder.birthdate) : null
}

View 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();
}
});

View File

@@ -42,7 +42,12 @@ da solche Dateien vom Quellmodell-Build übersprungen werden.
Unmarkierte Modelle behalten ihr bisheriges Aussehen. Die vollständige
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

View File

@@ -11,6 +11,7 @@
<div class="director-main">
<div class="director-visual" v-if="director.character">
<FalukantCharacterVisual
:appearance="director.character"
:gender="director.character.gender"
:age="director.character.age"
:visual-settings="visualSettings"

View File

@@ -43,6 +43,7 @@
<td>
<div class="church-person-cell">
<FalukantCharacterVisual
:appearance="person"
:gender="person.gender"
:age="person.age"
:visual-settings="visualSettings"
@@ -91,6 +92,7 @@
<span v-if="pos.character">
<div class="church-person-cell">
<FalukantCharacterVisual
:appearance="pos.character"
:gender="pos.character.gender"
:age="pos.character.age"
:visual-settings="visualSettings"

View File

@@ -156,6 +156,7 @@
></div>
<div v-if="showCharacterFigure3d" class="character-3d-frame character-3d-frame--spouse">
<Character3D
:appearance="relationships[0].character2"
:gender="relationships[0].character2.gender"
:age="relationships[0].character2.age"
/>
@@ -341,6 +342,7 @@
:style="getChild3dBackgroundStyle(child)"
>
<Character3D
:appearance="child"
:gender="child.gender"
:age="child.age"
:lightweight="true"
@@ -452,6 +454,7 @@
></div>
<div v-if="showCharacterFigure3d" class="character-3d-frame character-3d-frame--compact">
<Character3D
:appearance="lover"
:gender="lover.gender"
:age="lover.age"
:lightweight="true"

View File

@@ -18,6 +18,7 @@
<div class="politics-card__meta">
<div v-if="pos.character" class="politics-card__visual">
<FalukantCharacterVisual
:appearance="pos.character"
:gender="pos.character.gender"
:age="pos.character.age"
:visual-settings="visualSettings"