Initial commit — Venice of Wasteland sim engine B1-B5

This commit is contained in:
Corback
2026-06-15 22:38:35 +00:00
commit 3039961b51
21 changed files with 3130 additions and 0 deletions
+264
View File
@@ -0,0 +1,264 @@
-- =============================================================================
-- Fixtures initiales — Fallout : Venice of Wasteland
-- Données issues de la Bible V2.0 (SOURCE DE VÉRITÉ)
-- =============================================================================
-- =============================================================================
-- FACTIONS
-- =============================================================================
INSERT INTO factions (slug, name, description, home_location_slug, base_disposition) VALUES
(
'syndicat_capitole',
'Le Syndicat du Capitole',
'Mafia gouvernementale et oligarchie. Anciens raiders ayant pris le contrôle des infrastructures de Baton Rouge. Contrôlent la flotte Waste Star Line et la Tour du Capitole.',
'baton_rouge',
'opportuniste'
),
(
'dynaste_oak',
'La Dynastie d''Oak',
'Aristocratie esclavagiste et industrielle. Descendants des "Maîtres" de l''Abri 84. Cultivent la canne à sucre mutante et raffinent l''éthanol.',
'oak_plantation',
'agressif'
),
(
'grand_krewe',
'Le Grand Krewe',
'Culte vaudou et société de goules. Théocratie de Goules, souvent d''anciens esclaves d''Oak. Maîtrisent les radiations et le vaudou.',
'la_paroisse',
'defensif'
),
(
'union',
'L''Union',
'Démocratie technologique issue de l''Abri 84. Contrôlent le seul point d''eau purifiée de la région — pouvoir économique vital.',
'independance',
'defensif'
),
(
'consortium',
'Le Consortium',
'Méritocratie corporatiste et paradis fiscal. Maîtrise des technologies de l''Ancien Monde. Neutres mais opportunistes.',
'laplace',
'neutre'
),
(
'ecumeurs',
'Les Écumeurs',
'Pirates fluviaux sur airboats. Contrôlent les routes commerciales du Mississippi. Imprévisibles.',
'pearl_river',
'opportuniste'
),
(
'regie',
'La Régie',
'Élite survivante de NOLA. Maintiennent le Vieux Carré au sec grâce au contrôle hydraulique. Vivent dans le luxe relatif.',
'nola_vieux_carre',
'defensif'
),
(
'cda',
'La CdA (USS Kidd)',
'Faction militaire sur le USS Kidd reconverti. Structure paramilitaire, disciplinée.',
'nola_fleuve',
'defensif'
);
-- =============================================================================
-- LIEUX
-- =============================================================================
INSERT INTO locations (slug, name, region, location_type, controlled_by_faction, radiation_level, description, is_shelter) VALUES
(
'independance',
'Indépendance (Vault 84)',
'delta',
'vault',
'union',
1,
'Ville fortifiée construite sur le parvis de l''Abri 84. Zone 1 : bidonville boueux (Parvis). Zone 2 : cabanes ordonnées et cultures hydroponiques (Citadelle). Zone 3 : l''Abri souterrain (Vault 84, air filtré). Radiation quasi nulle à l''intérieur.',
TRUE
),
(
'laplace',
'LaPlace (Le Carrefour Suspendu)',
'delta',
'carrefour',
'consortium',
2,
'Ville bâtie sur un échangeur routier surélevé pour échapper aux crues et aux créatures. ~150 résidents permanents, ~100 visiteurs quotidiens. Nœud commercial central.',
FALSE
),
(
'baton_rouge',
'Baton Rouge',
'baton_rouge',
'ville',
'syndicat_capitole',
3,
'Ancien centre administratif de la Louisiane. Tour du Capitole tenue par le Syndicat. Port fluvial actif. QG de la flotte Waste Star Line.',
FALSE
),
(
'oak_plantation',
'Oak Plantation',
'bayou',
'plantation',
'dynaste_oak',
2,
'Grande plantation esclavagiste reconstituée. Champs de canne à sucre mutante. Raffinerie d''éthanol. Murs hauts, gardes armés. Abri 84 nearby (source du conflit originel).',
FALSE
),
(
'la_paroisse',
'La Paroisse (Necropolis)',
'nola',
'lieu_sauvage',
'grand_krewe',
7,
'Zone radioactive à l''est de NOLA. Territoire des Goules du Grand Krewe. Le Tertre : silo enterré reconverti en QG. Salle du Trône : "La Cour des Miracles".',
FALSE
),
(
'nola_vieux_carre',
'NOLA — Vieux Carré',
'nola',
'ville',
'regie',
2,
'Quartier historique tenu par La Régie. Maintenu au sec par pompage hydraulique. Muré. Zone sèche et relativement luxueuse.',
FALSE
),
(
'nola_cbd',
'NOLA — CBD (Le Récif)',
'nola',
'lieu_sauvage',
NULL,
4,
'Gratte-ciels inondés. Zone de guerre entre factions. Contrôlé par des Super Mutants. Aucune faction n''y règne vraiment.',
FALSE
),
(
'nola_fleuve',
'NOLA — Port Fluvial',
'nola',
'carrefour',
'cda',
3,
'Le USS Kidd reconverti en QG de la CdA. Point de passage obligé pour le commerce fluvial vers le sud.',
FALSE
),
(
'pearl_river',
'Pearl River',
'bayou',
'lieu_sauvage',
'ecumeurs',
3,
'Base des Écumeurs. Réseau de caches et de bases flottantes. Difficile d''accès, contrôle les routes secondaires.',
FALSE
),
(
'donaldsonville',
'Donaldsonville',
'delta',
'ville',
NULL,
4,
'Ville intermédiaire entre Oak Plantation et NOLA. Indépendante de fait, enjeu géopolitique entre factions du sud.',
FALSE
);
-- =============================================================================
-- RELATIONS INITIALES ENTRE FACTIONS
-- (score : -100=guerre, 0=neutre, +100=alliance)
-- Ordre alphabétique sur slug pour respecter la contrainte CHECK
-- =============================================================================
INSERT INTO faction_relations (session_id, faction_a, faction_b, relation_score, relation_label, updated_day)
SELECT 1, a, b, score, label, 0
FROM (VALUES
-- Syndicat vs autres
('consortium', 'syndicat_capitole', -20, 'tension'),
('dynaste_oak', 'syndicat_capitole', -40, 'tension'),
('ecumeurs', 'syndicat_capitole', 10, 'commerce'),
('grand_krewe', 'syndicat_capitole', -10, 'neutre'),
('regie', 'syndicat_capitole', 20, 'commerce'),
('syndicat_capitole', 'union', -50, 'tension'),
-- Union vs autres
('cda', 'union', 30, 'commerce'),
('consortium', 'union', 10, 'neutre'),
('dynaste_oak', 'union', -60, 'tension'),
('ecumeurs', 'union', -20, 'tension'),
('grand_krewe', 'union', 15, 'neutre'),
-- Dynaste vs autres
('cda', 'dynaste_oak', -10, 'neutre'),
('consortium', 'dynaste_oak', -30, 'tension'),
('dynaste_oak', 'ecumeurs', -20, 'tension'),
('dynaste_oak', 'grand_krewe', -80, 'guerre'),
('dynaste_oak', 'regie', -10, 'neutre'),
-- Grand Krewe vs autres
('cda', 'grand_krewe', -5, 'neutre'),
('consortium', 'grand_krewe', 0, 'neutre'),
('ecumeurs', 'grand_krewe', 20, 'neutre'),
('grand_krewe', 'regie', -30, 'tension'),
-- Consortium vs autres
('cda', 'consortium', 15, 'commerce'),
('consortium', 'ecumeurs', 30, 'commerce'),
('consortium', 'regie', 20, 'commerce'),
-- Écumeurs vs autres
('cda', 'ecumeurs', -40, 'tension'),
('ecumeurs', 'regie', 10, 'neutre'),
-- CdA vs Régie
('cda', 'regie', 25, 'commerce')
) AS t(faction_a, faction_b, score, label);
-- =============================================================================
-- SESSION INITIALE
-- =============================================================================
INSERT INTO sessions (name, seed_global, mode, current_day, current_tick, status)
VALUES ('Simulation Alpha — 5 PNJ × 30 jours', 42, 'pacifique', 0, 0, 'active');
-- =============================================================================
-- PNJ SIMULÉS INITIAUX (1 par faction principale)
-- =============================================================================
INSERT INTO characters (
session_id, name, character_type, faction_slug, location_slug, seed_base,
strength, perception, endurance, charisma, intelligence, agility, luck,
hp, max_hp, caps
) VALUES
(1, 'Kingfish Robichaux', 'pnj_sim', 'syndicat_capitole', 'baton_rouge', 1001, 6,5,5,8,7,4,6, 100,100, 5000),
(1, 'Maître Beaumont', 'pnj_sim', 'dynaste_oak', 'oak_plantation', 1002, 8,4,7,5,4,5,3, 120,120, 3000),
(1, 'Mme LaVeau', 'pnj_sim', 'grand_krewe', 'la_paroisse', 1003, 4,8,6,9,7,5,8, 80, 80, 1500),
(1, 'Commissaire Delacroix','pnj_sim','union', 'independance', 1004, 5,7,6,6,9,6,5, 100,100, 4000),
(1, 'Directeur Tran', 'pnj_sim', 'consortium', 'laplace', 1005, 4,6,4,7,9,7,7, 90, 90, 8000);
-- =============================================================================
-- ÉTAT COMPORTEMENTAL INITIAL DES PNJ (Jour 0, Tick 0)
-- =============================================================================
INSERT INTO pnj_state (character_id, session_id, day, tick, mood, stress_level, seed_current, last_actions, summary)
SELECT id, 1, 0, 0,
CASE faction_slug
WHEN 'syndicat_capitole' THEN 'opportuniste'
WHEN 'dynaste_oak' THEN 'agressif'
WHEN 'grand_krewe' THEN 'defensif'
WHEN 'union' THEN 'defensif'
WHEN 'consortium' THEN 'neutre'
END,
0, seed_base, '[]', 'État initial — pré-simulation.'
FROM characters WHERE session_id = 1 AND character_type = 'pnj_sim';
-- =============================================================================
-- ÉTAT DU MONDE INITIAL (Jour 0)
-- =============================================================================
INSERT INTO world_state (session_id, location_slug, day, water_level, food_level, security_level, pop_count)
VALUES
(1, 'independance', 0, 90, 70, 80, 850),
(1, 'laplace', 0, 60, 75, 65, 250),
(1, 'baton_rouge', 0, 55, 60, 50, 600),
(1, 'oak_plantation', 0, 40, 85, 40, 400),
(1, 'la_paroisse', 0, 30, 40, 55, 300),
(1, 'nola_vieux_carre', 0, 70, 65, 70, 200),
(1, 'nola_cbd', 0, 20, 20, 10, 50),
(1, 'nola_fleuve', 0, 50, 55, 60, 100),
(1, 'pearl_river', 0, 45, 50, 30, 150),
(1, 'donaldsonville', 0, 35, 45, 35, 180);
+37
View File
@@ -0,0 +1,37 @@
#!/bin/bash
# =============================================================================
# init_db.sh — Initialisation DB Fallout sur Vigile
# Usage : bash init_db.sh
# Pré-requis : psql installé, accès SSH à Vigile ou exécuté directement dessus
# =============================================================================
set -e
DB_HOST="${DB_HOST:-localhost}"
DB_PORT="${DB_PORT:-5432}"
DB_NAME="${DB_NAME:-fallout}"
DB_USER="${DB_USER:-fallout}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
echo "=== Fallout DB Init ==="
echo "Host : $DB_HOST:$DB_PORT | DB : $DB_NAME | User : $DB_USER"
echo ""
# Appliquer le schéma
echo "[1/2] Application du schéma..."
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" \
-f "$SCRIPT_DIR/schema.sql" \
&& echo " Schéma OK" \
|| { echo "ERREUR schéma"; exit 1; }
# Appliquer les fixtures
echo "[2/2] Insertion des fixtures..."
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" \
-f "$SCRIPT_DIR/fixtures.sql" \
&& echo " Fixtures OK" \
|| { echo "ERREUR fixtures"; exit 1; }
echo ""
echo "=== DB initialisée avec succès ==="
echo "Session 1 : 'Simulation Alpha — 5 PNJ × 30 jours' (mode pacifique)"
echo "Factions : 8 | Lieux : 10 | PNJ simulés : 5"
@@ -0,0 +1,82 @@
-- =============================================================================
-- Migration 001 — Correction HP (END+LCK) + Table inventory
-- Règle officielle Fallout 2D20 Rules Booklet p.13 : max_hp = END + LCK
-- =============================================================================
-- Recalcul max_hp et hp pour tous les personnages existants
UPDATE characters
SET
max_hp = endurance + luck,
hp = endurance + luck
WHERE session_id = 1;
-- =============================================================================
-- Table inventory
-- Poids max = 150 + (STR * 10) — Rules Booklet p.12
-- =============================================================================
CREATE TABLE IF NOT EXISTS inventory (
id SERIAL PRIMARY KEY,
character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
session_id INTEGER NOT NULL,
item_slug TEXT NOT NULL,
item_name TEXT NOT NULL,
item_type TEXT NOT NULL, -- weapon / armor / food / drink / drug / misc / ammo
quantity INTEGER DEFAULT 1,
weight NUMERIC(6,2) DEFAULT 0,
-- Stats optionnelles selon type
hp_heal INTEGER DEFAULT 0,
rad_heal INTEGER DEFAULT 0,
damage_dice INTEGER DEFAULT 0, -- CD (Combat Dice)
damage_bonus INTEGER DEFAULT 0,
damage_type TEXT, -- physical / energy / poison / radiation
damage_range TEXT, -- short / medium / long / extreme
armor_rating INTEGER DEFAULT 0,
properties JSONB DEFAULT '{}', -- effets spéciaux libres
is_equipped BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_inventory_character ON inventory(character_id, session_id);
-- =============================================================================
-- Équipements de départ pour les 5 PNJ
-- =============================================================================
INSERT INTO inventory (character_id, session_id, item_slug, item_name, item_type, quantity, weight, damage_dice, damage_type, damage_range, properties)
SELECT id, 1,
'pistolet_10mm', 'Pistolet 10mm', 'weapon', 1, 1.5, 2, 'physical', 'medium',
'{"special": "Bruyant"}'::jsonb
FROM characters WHERE session_id=1 AND name='Kingfish Robichaux';
INSERT INTO inventory (character_id, session_id, item_slug, item_name, item_type, quantity, weight, damage_dice, damage_type, damage_range, properties)
SELECT id, 1,
'fusil_chasse', 'Fusil de chasse', 'weapon', 1, 3.0, 3, 'physical', 'medium',
'{"special": "Dispersion"}'::jsonb
FROM characters WHERE session_id=1 AND name='Maître Beaumont';
INSERT INTO inventory (character_id, session_id, item_slug, item_name, item_type, quantity, weight, damage_dice, damage_type, damage_range, properties)
SELECT id, 1,
'baton_vaudou', 'Bâton de Mambo', 'weapon', 1, 1.0, 2, 'physical', 'short',
'{"special": "Crit+Poison"}'::jsonb
FROM characters WHERE session_id=1 AND name='Mme LaVeau';
INSERT INTO inventory (character_id, session_id, item_slug, item_name, item_type, quantity, weight, damage_dice, damage_type, damage_range, properties)
SELECT id, 1,
'pistolet_laser', 'Pistolet laser', 'weapon', 1, 1.0, 2, 'energy', 'medium',
'{"special": "Crit+Brûlure"}'::jsonb
FROM characters WHERE session_id=1 AND name='Commissaire Delacroix';
INSERT INTO inventory (character_id, session_id, item_slug, item_name, item_type, quantity, weight, damage_dice, damage_type, damage_range, properties)
SELECT id, 1,
'pistolet_10mm', 'Pistolet 10mm', 'weapon', 1, 1.5, 2, 'physical', 'medium',
'{"special": "Bruyant"}'::jsonb
FROM characters WHERE session_id=1 AND name='Directeur Tran';
-- Nourriture de départ (3 rations chacun)
INSERT INTO inventory (character_id, session_id, item_slug, item_name, item_type, quantity, weight, hp_heal, properties)
SELECT id, 1, 'cram', 'Cram', 'food', 3, 0.5, 5, '{}'::jsonb
FROM characters WHERE session_id=1;
-- Eau sale (à purifier)
INSERT INTO inventory (character_id, session_id, item_slug, item_name, item_type, quantity, weight, properties)
SELECT id, 1, 'eau_sale', 'Eau sale', 'drink', 2, 0.5, '{"irradiated": true, "rad_on_drink": 1}'::jsonb
FROM characters WHERE session_id=1;
@@ -0,0 +1,169 @@
-- =============================================================================
-- Migration 002 — Ajout role/origin/traits sur characters
-- Remplacement table inventory (schéma cohérent avec le code)
-- =============================================================================
-- -----------------------------------------------------------------------------
-- 1. Colonnes manquantes sur characters
-- -----------------------------------------------------------------------------
ALTER TABLE characters
ADD COLUMN IF NOT EXISTS role TEXT DEFAULT 'survivant_generique',
ADD COLUMN IF NOT EXISTS origin TEXT DEFAULT 'survivant',
ADD COLUMN IF NOT EXISTS needs_food BOOLEAN DEFAULT TRUE,
ADD COLUMN IF NOT EXISTS needs_water BOOLEAN DEFAULT TRUE,
ADD COLUMN IF NOT EXISTS needs_sleep BOOLEAN DEFAULT TRUE,
ADD COLUMN IF NOT EXISTS immune_radiation BOOLEAN DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS immune_poison BOOLEAN DEFAULT FALSE;
-- -----------------------------------------------------------------------------
-- 2. Remplacement de la table inventory
-- Le schéma original (schema.sql) utilisait owner_type/owner_id/item_category.
-- Le code (db.py, migration_001) utilise character_id/session_id/item_type/weight/...
-- On supprime l'ancienne structure et on recrée proprement.
-- -----------------------------------------------------------------------------
DROP TABLE IF EXISTS inventory CASCADE;
CREATE TABLE inventory (
id SERIAL PRIMARY KEY,
character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
item_slug TEXT NOT NULL,
item_name TEXT NOT NULL,
-- weapon_melee | weapon_ranged | weapon_energy | armor | food | drink | drug | ammo | misc
item_type TEXT NOT NULL,
quantity INTEGER DEFAULT 1,
weight NUMERIC(6,2) DEFAULT 0, -- kg (Manuel FR)
-- Stats optionnelles
hp_heal INTEGER DEFAULT 0,
rad_heal INTEGER DEFAULT 0,
damage_dice INTEGER DEFAULT 0, -- nombre de CD (dés de combat)
damage_bonus INTEGER DEFAULT 0,
damage_type TEXT, -- physical | energy | poison | radiation
damage_range TEXT, -- short | medium | long | extreme
armor_rating INTEGER DEFAULT 0,
properties JSONB DEFAULT '{}', -- effets libres (Brutal, Perforant X, etc.)
is_equipped BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (character_id, session_id, item_slug)
);
CREATE INDEX idx_inventory_character ON inventory(character_id, session_id);
-- -----------------------------------------------------------------------------
-- 3. Équipement de départ pour les 10 PNJ (session 1)
-- Arme selon rôle + 3 rations + 2 eaux sales
-- -----------------------------------------------------------------------------
-- Gardes → pistolet 10mm équipé
INSERT INTO inventory
(character_id, session_id, item_slug, item_name, item_type,
quantity, weight, damage_dice, damage_type, damage_range, is_equipped)
SELECT id, 1, 'pistolet_10mm', 'Pistolet 10mm', 'weapon_ranged',
1, 1.5, 2, 'physical', 'medium', TRUE
FROM characters
WHERE session_id = 1 AND role = 'garde'
ON CONFLICT (character_id, session_id, item_slug) DO NOTHING;
-- Éclaireurs → fusil de chasse équipé
INSERT INTO inventory
(character_id, session_id, item_slug, item_name, item_type,
quantity, weight, damage_dice, damage_type, damage_range, is_equipped)
SELECT id, 1, 'fusil_chasse', 'Fusil de chasse', 'weapon_ranged',
1, 3.0, 3, 'physical', 'medium', TRUE
FROM characters
WHERE session_id = 1 AND role = 'eclaireur'
ON CONFLICT (character_id, session_id, item_slug) DO NOTHING;
-- Pillards → couteau de combat équipé
INSERT INTO inventory
(character_id, session_id, item_slug, item_name, item_type,
quantity, weight, damage_dice, damage_type, damage_range, is_equipped)
SELECT id, 1, 'couteau_combat', 'Couteau de combat', 'weapon_melee',
1, 0.5, 2, 'physical', 'short', TRUE
FROM characters
WHERE session_id = 1 AND role = 'pillard'
ON CONFLICT (character_id, session_id, item_slug) DO NOTHING;
-- Marchands → pistolet 10mm équipé
INSERT INTO inventory
(character_id, session_id, item_slug, item_name, item_type,
quantity, weight, damage_dice, damage_type, damage_range, is_equipped)
SELECT id, 1, 'pistolet_10mm', 'Pistolet 10mm', 'weapon_ranged',
1, 1.5, 2, 'physical', 'medium', TRUE
FROM characters
WHERE session_id = 1 AND role = 'marchand'
ON CONFLICT (character_id, session_id, item_slug) DO NOTHING;
-- Docteur → stimpaks (outil de travail)
INSERT INTO inventory
(character_id, session_id, item_slug, item_name, item_type,
quantity, weight, hp_heal)
SELECT id, 1, 'stimpak', 'Stimpak', 'drug', 3, 0.2, 4
FROM characters
WHERE session_id = 1 AND role = 'docteur'
ON CONFLICT (character_id, session_id, item_slug) DO NOTHING;
-- Technicien → pistolet laser équipé
INSERT INTO inventory
(character_id, session_id, item_slug, item_name, item_type,
quantity, weight, damage_dice, damage_type, damage_range, is_equipped)
SELECT id, 1, 'pistolet_laser', 'Pistolet laser', 'weapon_energy',
1, 1.0, 2, 'energy', 'medium', TRUE
FROM characters
WHERE session_id = 1 AND role = 'technicien'
ON CONFLICT (character_id, session_id, item_slug) DO NOTHING;
-- Survivant générique → bâton (mains nues si absent)
INSERT INTO inventory
(character_id, session_id, item_slug, item_name, item_type,
quantity, weight, damage_dice, damage_type, damage_range, is_equipped)
SELECT id, 1, 'baton', 'Bâton', 'weapon_melee',
1, 1.0, 1, 'physical', 'short', TRUE
FROM characters
WHERE session_id = 1 AND role = 'survivant_generique'
ON CONFLICT (character_id, session_id, item_slug) DO NOTHING;
-- Nourriture de départ : 3 rations Cram pour tous
INSERT INTO inventory
(character_id, session_id, item_slug, item_name, item_type,
quantity, weight, hp_heal)
SELECT id, 1, 'cram', 'Cram', 'food', 3, 0.5, 2
FROM characters WHERE session_id = 1
ON CONFLICT (character_id, session_id, item_slug) DO NOTHING;
-- Eau sale (2 unités, irradiée) pour tous
INSERT INTO inventory
(character_id, session_id, item_slug, item_name, item_type,
quantity, weight, properties)
SELECT id, 1, 'eau_sale', 'Eau sale', 'drink', 2, 0.5,
'{"irradiated": true, "rad_on_drink": 1}'::jsonb
FROM characters WHERE session_id = 1
ON CONFLICT (character_id, session_id, item_slug) DO NOTHING;
-- Marchands → stock supplémentaire (pour le commerce)
INSERT INTO inventory
(character_id, session_id, item_slug, item_name, item_type,
quantity, weight, hp_heal)
SELECT id, 1, 'igname_cuite', 'Igname cuite', 'food', 5, 0.3, 3
FROM characters WHERE session_id = 1 AND role = 'marchand'
ON CONFLICT (character_id, session_id, item_slug) DO NOTHING;
-- -----------------------------------------------------------------------------
-- 4. Recalcul HP selon règle officielle (END + LCK) — Manuel FR p.51
-- -----------------------------------------------------------------------------
UPDATE characters
SET max_hp = endurance + luck,
hp = endurance + luck
WHERE session_id = 1;
-- -----------------------------------------------------------------------------
-- 5. Mise à jour traits origine pour les PNJ existants
-- -----------------------------------------------------------------------------
UPDATE characters SET immune_radiation = TRUE, needs_food = TRUE
WHERE session_id = 1 AND origin = 'goule';
UPDATE characters SET immune_radiation = TRUE, immune_poison = TRUE
WHERE session_id = 1 AND origin IN ('super_mutant', 'mister_handy');
UPDATE characters SET needs_food = FALSE, needs_water = FALSE, needs_sleep = FALSE
WHERE session_id = 1 AND origin = 'mister_handy';
+226
View File
@@ -0,0 +1,226 @@
-- =============================================================================
-- Fallout : Venice of Wasteland — Schéma PostgreSQL
-- DB: fallout | Conteneur: fallout_db | Vigile
-- =============================================================================
-- Extensions
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- =============================================================================
-- SESSIONS DE JEU
-- =============================================================================
CREATE TABLE sessions (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
seed_global INTEGER NOT NULL,
mode TEXT NOT NULL DEFAULT 'normal', -- 'pacifique', 'normal'
current_day INTEGER NOT NULL DEFAULT 0,
current_tick INTEGER NOT NULL DEFAULT 0,
started_at TIMESTAMPTZ DEFAULT NOW(),
status TEXT NOT NULL DEFAULT 'active' -- 'active', 'paused', 'completed'
);
-- =============================================================================
-- FACTIONS
-- =============================================================================
CREATE TABLE factions (
id SERIAL PRIMARY KEY,
slug TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
description TEXT,
home_location_slug TEXT,
base_disposition TEXT NOT NULL DEFAULT 'neutre'
-- 'agressif' | 'defensif' | 'neutre' | 'opportuniste'
);
-- =============================================================================
-- LIEUX
-- =============================================================================
CREATE TABLE locations (
id SERIAL PRIMARY KEY,
slug TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
region TEXT, -- 'baton_rouge' | 'delta' | 'nola' | 'bayou' | 'pearl_river'
location_type TEXT, -- 'vault' | 'ville' | 'lieu_sauvage' | 'plantation' | 'carrefour'
controlled_by_faction TEXT REFERENCES factions(slug),
radiation_level INTEGER DEFAULT 0, -- 0-10
description TEXT,
is_shelter BOOLEAN DEFAULT FALSE -- Mode Abri activable
);
-- =============================================================================
-- PERSONNAGES (PNJ simulés + joueurs humains)
-- =============================================================================
CREATE TABLE characters (
id SERIAL PRIMARY KEY,
session_id INTEGER REFERENCES sessions(id) ON DELETE CASCADE,
name TEXT NOT NULL,
character_type TEXT NOT NULL, -- 'pnj_sim' | 'joueur'
faction_slug TEXT REFERENCES factions(slug),
location_slug TEXT REFERENCES locations(slug),
seed_base INTEGER NOT NULL,
-- Stats SPECIAL
strength INTEGER DEFAULT 5,
perception INTEGER DEFAULT 5,
endurance INTEGER DEFAULT 5,
charisma INTEGER DEFAULT 5,
intelligence INTEGER DEFAULT 5,
agility INTEGER DEFAULT 5,
luck INTEGER DEFAULT 5,
-- État vital
hp INTEGER DEFAULT 100,
max_hp INTEGER DEFAULT 100,
caps INTEGER DEFAULT 0,
rads INTEGER DEFAULT 0, -- niveau de radiation accumulé
is_alive BOOLEAN DEFAULT TRUE,
is_in_shelter BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- =============================================================================
-- ÉTAT COMPORTEMENTAL PNJ (recalculé à chaque cycle)
-- =============================================================================
CREATE TABLE pnj_state (
id SERIAL PRIMARY KEY,
character_id INTEGER REFERENCES characters(id) ON DELETE CASCADE,
session_id INTEGER REFERENCES sessions(id) ON DELETE CASCADE,
day INTEGER NOT NULL,
tick INTEGER NOT NULL,
-- Humeur courante (dérivée de seed_base + actions passées)
mood TEXT NOT NULL DEFAULT 'neutre',
-- 'agressif' | 'defensif' | 'neutre' | 'opportuniste' | 'craintif' | 'diplomatique'
stress_level INTEGER DEFAULT 0, -- 0-100
seed_current INTEGER NOT NULL, -- seed recalculée ce cycle
last_actions JSONB DEFAULT '[]', -- actions du cycle précédent
summary TEXT, -- résumé narratif MJ de ce tick
UNIQUE(session_id, character_id, day, tick)
);
-- =============================================================================
-- RELATIONS ENTRE FACTIONS
-- =============================================================================
CREATE TABLE faction_relations (
id SERIAL PRIMARY KEY,
session_id INTEGER REFERENCES sessions(id) ON DELETE CASCADE,
faction_a TEXT REFERENCES factions(slug),
faction_b TEXT REFERENCES factions(slug),
relation_score INTEGER DEFAULT 0,
-- -100 (guerre totale) → 0 (neutre) → +100 (alliance)
relation_label TEXT DEFAULT 'neutre',
-- 'guerre' | 'tension' | 'neutre' | 'commerce' | 'allie'
updated_day INTEGER DEFAULT 0,
UNIQUE(session_id, faction_a, faction_b),
CHECK (faction_a < faction_b) -- évite les doublons A-B / B-A
);
-- =============================================================================
-- INVENTAIRE (personnages et lieux)
-- =============================================================================
CREATE TABLE inventory (
id SERIAL PRIMARY KEY,
owner_type TEXT NOT NULL, -- 'character' | 'location'
owner_id INTEGER NOT NULL,
item_slug TEXT NOT NULL,
item_name TEXT NOT NULL,
item_category TEXT, -- 'arme' | 'nourriture' | 'medecine' | 'ressource' | 'misc'
quantity INTEGER DEFAULT 1,
condition_pct INTEGER DEFAULT 100 -- 0-100%
);
-- =============================================================================
-- COMPAGNONS / TAMAGOTCHI
-- =============================================================================
CREATE TABLE companions (
id SERIAL PRIMARY KEY,
character_id INTEGER REFERENCES characters(id) ON DELETE CASCADE,
companion_type TEXT NOT NULL,
-- 'rodeur_bayou' | 'goule_lucide' | 'protectron_casse' | 'enfant_marais'
-- | 'radcorbin' | 'brahmine_poche'
name TEXT NOT NULL,
-- Besoins vitaux (0 = danger, 100 = optimal)
hunger INTEGER DEFAULT 80,
thirst INTEGER DEFAULT 80,
health INTEGER DEFAULT 100,
happiness INTEGER DEFAULT 80,
loyalty INTEGER DEFAULT 100,
is_alive BOOLEAN DEFAULT TRUE,
last_fed_at TIMESTAMPTZ DEFAULT NOW(),
last_cared_at TIMESTAMPTZ DEFAULT NOW()
);
-- =============================================================================
-- ACTIONS EN ATTENTE (queue joueurs, 4 max/jour)
-- =============================================================================
CREATE TABLE pending_actions (
id SERIAL PRIMARY KEY,
session_id INTEGER REFERENCES sessions(id) ON DELETE CASCADE,
character_id INTEGER REFERENCES characters(id) ON DELETE CASCADE,
day INTEGER NOT NULL,
action_slot INTEGER NOT NULL CHECK (action_slot BETWEEN 1 AND 4),
action_type TEXT NOT NULL, -- 'deplacement' | 'commerce' | 'combat' | 'diplomatie' | 'craft' | 'repos'
action_description TEXT NOT NULL,
target_character_id INTEGER REFERENCES characters(id),
target_location_slug TEXT REFERENCES locations(slug),
submitted_at TIMESTAMPTZ DEFAULT NOW(),
processed BOOLEAN DEFAULT FALSE,
UNIQUE(session_id, character_id, day, action_slot)
);
-- =============================================================================
-- ÉTAT DU MONDE PAR ZONE (snapshot quotidien)
-- =============================================================================
CREATE TABLE world_state (
id SERIAL PRIMARY KEY,
session_id INTEGER REFERENCES sessions(id) ON DELETE CASCADE,
location_slug TEXT REFERENCES locations(slug),
day INTEGER NOT NULL,
water_level INTEGER DEFAULT 50, -- 0-100
food_level INTEGER DEFAULT 50,
security_level INTEGER DEFAULT 50,
pop_count INTEGER DEFAULT 0,
notable_events JSONB DEFAULT '[]', -- résumé des events du jour sur ce lieu
UNIQUE(session_id, location_slug, day)
);
-- =============================================================================
-- ÉVÉNEMENTS (log narratif complet)
-- =============================================================================
CREATE TABLE events (
id SERIAL PRIMARY KEY,
session_id INTEGER REFERENCES sessions(id) ON DELETE CASCADE,
day INTEGER NOT NULL,
tick INTEGER NOT NULL,
event_type TEXT NOT NULL,
-- 'action' | 'combat' | 'diplomatie' | 'commerce' | 'incident' | 'meteo' | 'cycle_mj'
actor_id INTEGER REFERENCES characters(id),
target_id INTEGER REFERENCES characters(id),
location_slug TEXT REFERENCES locations(slug),
description TEXT NOT NULL, -- narration MJ
mechanical_effect JSONB,
-- ex: {"hp_change": -20, "caps_change": 50, "relation_change": {"factions": ["syndicat","union"], "delta": -5}}
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- =============================================================================
-- WIKI ENTRIES (export vers MkDocs fallout.coyoteos.ovh)
-- =============================================================================
CREATE TABLE wiki_entries (
id SERIAL PRIMARY KEY,
session_id INTEGER REFERENCES sessions(id) ON DELETE CASCADE,
entry_type TEXT NOT NULL, -- 'faction' | 'lieu' | 'pnj' | 'evenement' | 'chronologie'
slug TEXT NOT NULL,
title TEXT NOT NULL,
content TEXT NOT NULL, -- Markdown
day_written INTEGER,
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(session_id, entry_type, slug)
);
-- =============================================================================
-- INDEX (performances)
-- =============================================================================
CREATE INDEX idx_events_session_day ON events(session_id, day);
CREATE INDEX idx_pnj_state_char_day ON pnj_state(character_id, day);
CREATE INDEX idx_world_state_session_day ON world_state(session_id, day);
CREATE INDEX idx_pending_actions_processed ON pending_actions(session_id, processed, day);
CREATE INDEX idx_inventory_owner ON inventory(owner_type, owner_id);
+454
View File
@@ -0,0 +1,454 @@
"""
Brique 5 — Actions PNJ
Décision IA basée sur priorités + résolution mécanique 2D20.
Appelé chaque tick depuis tick.py après survival.process_survival_tick().
Règles (Résumé Écran MJ + Résumé des Règles FR) :
Test : 2d20, réussite si dé ≤ Attribut + rang_compétence (1 = crit = 2 réussites)
Difficulté : nombre de réussites requises (= défense cible pour attaque)
Attaque CàC: FOR + Corps à corps | Distance légère: AGI + Arme légère
Distance lourde: END + Armes lourdes | Énergie: PER + Arme à énergie
Dés combat : 1→1dmg, 2→2dmg, 3-4→0, 5-6→1dmg+effet
Déplacement: 1 zone (action mineure) ou 2 zones sprint (capitale)
Encombrement >carry: pas de sprint, -1 init, FOR/AGI difficulté+1
"""
import random
import db
import inventory as inv_module
# ---------------------------------------------------------------------------
# Rang de compétence par défaut (sans table skills complète en DB)
# Seuil de réussite = attribut + rang
# ---------------------------------------------------------------------------
_DEFAULT_SKILL_RANK: dict[str, int] = {
"corps_a_corps": 2,
"arme_legere": 2,
"armes_lourdes": 2,
"arme_energie": 1,
"discours": 2,
"troc": 2,
"survie": 2,
"medecine": 1,
"athletisme": 2,
}
_ROLE_SKILL_BONUS: dict[str, dict[str, int]] = {
"pillard": {"corps_a_corps": 1, "arme_legere": 1},
"raider_boss": {"corps_a_corps": 2, "discours": 1},
"garde": {"arme_legere": 1, "corps_a_corps": 1},
"eclaireur": {"arme_legere": 2, "survie": 1},
"marchand": {"troc": 2, "discours": 1},
"docteur": {"medecine": 2},
"technicien": {},
"survivant_generique":{"survie": 1},
}
# Paires de factions hostiles l'une envers l'autre
_HOSTILE_PAIRS: set[frozenset] = {
frozenset({"ecumeurs", "union"}),
frozenset({"ecumeurs", "cda"}),
frozenset({"ecumeurs", "regie"}),
frozenset({"ecumeurs", "consortium"}),
frozenset({"ecumeurs", "grand_krewe"}),
}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _are_hostile(char_a: dict, char_b: dict) -> bool:
f1 = char_a.get("faction_slug")
f2 = char_b.get("faction_slug")
if not f1 or not f2 or f1 == f2:
return False
return frozenset({f1, f2}) in _HOSTILE_PAIRS
def _skill_rank(char: dict, skill: str) -> int:
role = char.get("role", "survivant_generique")
base = _DEFAULT_SKILL_RANK.get(skill, 1)
bonus = _ROLE_SKILL_BONUS.get(role, {}).get(skill, 0)
return base + bonus
def roll_2d20(attribute: int, skill_rank: int, n_dice: int = 2) -> dict:
"""
Lance n_dice d20. Réussite si dé ≤ attribute + skill_rank.
Résultat 1 = réussite critique (compte double).
Résultat 20 = complication.
"""
sr = attribute + skill_rank
rolls = [random.randint(1, 20) for _ in range(n_dice)]
successes = sum(2 if r == 1 else 1 for r in rolls if r <= sr)
complications = sum(1 for r in rolls if r == 20)
return {
"rolls": rolls,
"sr": sr,
"successes": successes,
"complications": complications,
}
def roll_cd(n_dice: int) -> dict:
"""
Lance n_dice dés de combat (CD, d6).
1 → 1 dégât | 2 → 2 dégâts | 3-4 → 0 | 5-6 → 1 dégât + 1 effet
"""
damage = 0
effects = 0
rolls = []
for _ in range(max(1, n_dice)):
r = random.randint(1, 6)
rolls.append(r)
if r == 1:
damage += 1
elif r == 2:
damage += 2
elif r in (5, 6):
damage += 1
effects += 1
return {"rolls": rolls, "damage": damage, "effects": effects}
def _get_equipped_weapon(inventory: list[dict]) -> dict | None:
"""Retourne l'arme équipée, ou None (mains nues)."""
for item in inventory:
if item.get("is_equipped") and item.get("item_type") in (
"weapon_melee", "weapon_ranged", "weapon_energy"
):
return item
return None
# ---------------------------------------------------------------------------
# Résolution mécanique
# ---------------------------------------------------------------------------
def resolve_attack(attacker: dict, defender: dict,
weapon: dict | None,
session_id: int, day: int, tick: int) -> dict:
"""
Résout une attaque selon les règles 2D20.
Modifie la DB (HP défenseur, is_alive) et logue l'événement.
Retourne un résumé dict.
"""
# Attribut + compétence selon type d'arme
if weapon is None or weapon.get("item_type") == "weapon_melee":
attr_val = attacker["strength"]
skill = "corps_a_corps"
weapon_name = weapon["item_name"] if weapon else "mains nues"
elif weapon.get("item_type") == "weapon_energy":
attr_val = attacker["perception"]
skill = "arme_energie"
weapon_name = weapon["item_name"]
else:
attr_val = attacker["agility"]
skill = "arme_legere"
weapon_name = weapon["item_name"]
rank = _skill_rank(attacker, skill)
difficulty = 2 if defender["agility"] >= 9 else 1 # Manuel FR : AGI≥9→2, sinon→1
test = roll_2d20(attr_val, rank)
hit = test["successes"] >= difficulty
result: dict = {
"hit": hit,
"test": test,
"damage": 0,
"effects": 0,
"weapon": weapon["item_slug"] if weapon else "mains_nues",
"killed": False,
}
if hit:
n_cd = weapon.get("damage_dice", 1) if weapon else 1
melee_bonus = attacker.get("melee_bonus_cd", 0) if skill == "corps_a_corps" else 0
cd = roll_cd(n_cd + melee_bonus)
result["damage"] = cd["damage"]
result["effects"] = cd["effects"]
result["cd_rolls"] = cd["rolls"]
new_hp = max(0, defender["hp"] - result["damage"])
db.update_character(defender["id"], hp=new_hp)
if new_hp == 0:
db.update_character(defender["id"], is_alive=False)
result["killed"] = True
suffix = " [MORT]" if result["killed"] else f" -> {new_hp}/{defender['max_hp']} PV"
desc = (
f"{attacker['name']} touche {defender['name']} "
f"({weapon_name}) : -{result['damage']} PV{suffix}"
)
else:
desc = (
f"{attacker['name']} attaque {defender['name']} ({weapon_name}) — "
f"RATÉ (SR={test['sr']}, dés={test['rolls']})"
)
db.log_event(
session_id=session_id, day=day, tick=tick,
event_type="combat",
description=desc,
actor_id=attacker["id"],
location_slug=attacker.get("location_slug"),
mechanical_effect=result,
)
return result
# ---------------------------------------------------------------------------
# Décision IA
# ---------------------------------------------------------------------------
def _decide_action(char: dict, all_chars: list[dict],
states: dict, inventories: dict,
world_map: dict, rng: random.Random,
phase: str, mode: str) -> dict:
"""
Retourne un dict {"action": str, "target_id": int|None, "description": str, ...}.
Priorités :
1. Se soigner (PV ≤ 25% + stimpak dispo)
2. Attaquer ennemi dans même zone (si mode ≠ pacifique)
3. Manger (faim critique + nourriture dispo)
4. Boire (soif critique + eau dispo)
5. Dormir (épuisé + nuit)
6. Commerce (marchand proche, 15% chance)
7. Se déplacer (30% chance)
8. Idle
"""
char_id = char["id"]
state = states.get(char_id) or {}
# last_actions est un dict survival (hunger/thirst/sleep/fatigue) écrit par survival.py
sv = state.get("last_actions") or {}
if isinstance(sv, list):
sv = {}
inventory = inventories.get(char_id, [])
fatigue = sv.get("fatigue", 0)
hunger = sv.get("hunger", 4)
thirst = sv.get("thirst", 3)
hp_ratio = char["hp"] / max(1, char["max_hp"])
location = char.get("location_slug")
sleep = sv.get("sleep", 3)
# 1. Soins urgents
if hp_ratio <= 0.25:
stim = next((i for i in inventory if i["item_slug"] == "stimpak" and i["quantity"] >= 1), None)
if stim:
return {"action": "heal_self", "target_id": None,
"description": f"{char['name']} se soigne (PV critiques: {char['hp']}/{char['max_hp']})"}
# 2. Combat
if mode != "pacifique":
enemies = [
c for c in all_chars
if c["id"] != char_id
and c.get("location_slug") == location
and c.get("is_alive", True)
and _are_hostile(char, c)
]
if enemies:
target = rng.choice(enemies)
return {"action": "attack", "target_id": target["id"],
"description": f"{char['name']} attaque {target['name']}"}
# 3. Nourriture critique (hunger ≤ 1 = Faim ou Affamé)
if hunger <= 1:
food = next((i for i in inventory if i["item_type"] == "food" and i["quantity"] >= 1), None)
if food:
return {"action": "eat", "target_id": None,
"description": f"{char['name']} mange {food['item_name']} (faim critique)"}
return {"action": "forage", "target_id": None,
"description": f"{char['name']} cherche à manger"}
# 4. Eau critique (thirst ≤ 1 = Soif ou Déshydraté)
if thirst <= 1:
drink = next((i for i in inventory if i["item_type"] == "drink" and i["quantity"] >= 1), None)
if drink:
return {"action": "drink", "target_id": None,
"description": f"{char['name']} boit {drink['item_name']} (soif critique)"}
return {"action": "find_water", "target_id": None,
"description": f"{char['name']} cherche de l'eau"}
# 5. Sommeil si épuisé la nuit (sleep=0 → Épuisé, ou fatigue élevée)
if (sleep <= 0 or fatigue >= 3) and phase == "nuit":
return {"action": "sleep", "target_id": None,
"description": f"{char['name']} s'endort (fatigue={fatigue})"}
# 6. Commerce (15%)
if rng.random() < 0.15:
merchants = [
c for c in all_chars
if c["id"] != char_id
and c.get("location_slug") == location
and c.get("is_alive", True)
and c.get("role") == "marchand"
]
if merchants and char.get("caps", 0) > 10:
m = rng.choice(merchants)
return {"action": "trade", "target_id": m["id"],
"description": f"{char['name']} commerce avec {m['name']}"}
# 7. Déplacement (30%)
locations = [loc for loc in world_map if loc != location]
if locations and rng.random() < 0.30:
dest = rng.choice(locations)
return {"action": "move", "target_id": None,
"description": f"{char['name']} se déplace vers {dest}",
"destination": dest}
# 8. Idle
return {"action": "idle", "target_id": None,
"description": f"{char['name']} en veille"}
def _execute_action(char: dict, decision: dict,
chars_map: dict, inventories: dict,
session_id: int, day: int, tick: int) -> None:
"""Exécute l'action décidée et met à jour la DB."""
action = decision["action"]
char_id = char["id"]
inv = inventories.get(char_id, [])
if action == "heal_self":
stim = next((i for i in inv if i["item_slug"] == "stimpak" and i["quantity"] >= 1), None)
if stim:
heal = stim.get("hp_heal", 4)
new_hp = min(char["max_hp"], char["hp"] + heal)
db.update_character(char_id, hp=new_hp)
db.remove_item(char_id, session_id, "stimpak", 1)
db.log_event(
session_id=session_id, day=day, tick=tick,
event_type="soin",
description=f"{char['name']} utilise un Stimpak (+{heal} PV → {new_hp}/{char['max_hp']})",
actor_id=char_id,
location_slug=char.get("location_slug"),
mechanical_effect={"heal": heal, "hp_apres": new_hp},
)
elif action == "attack":
target = chars_map.get(decision["target_id"])
if target and target.get("is_alive", True):
weapon = _get_equipped_weapon(inv)
resolve_attack(char, target, weapon, session_id, day, tick)
elif action == "eat":
food = next((i for i in inv if i["item_type"] == "food" and i["quantity"] >= 1), None)
if food:
inv_module.consume_food(session_id, char_id, day, tick, food["item_slug"])
elif action == "drink":
drink = next((i for i in inv if i["item_type"] == "drink" and i["quantity"] >= 1), None)
if drink:
inv_module.consume_drink(session_id, char_id, day, tick, drink["item_slug"])
elif action == "move":
dest = decision.get("destination")
if dest:
db.update_character(char_id, location_slug=dest)
db.log_event(
session_id=session_id, day=day, tick=tick,
event_type="deplacement",
description=decision["description"],
actor_id=char_id,
location_slug=dest,
mechanical_effect={"from": char.get("location_slug"), "to": dest},
)
elif action == "trade":
target = chars_map.get(decision["target_id"])
if target:
# TODO B6/B7 : croiser inventaire vendeur + caps acheteur pour vrai échange
amount = min(char.get("caps", 0) // 4, 50)
if amount > 0:
db.update_character(char_id, caps=char["caps"] - amount)
db.update_character(target["id"], caps=target.get("caps", 0) + amount)
db.log_event(
session_id=session_id, day=day, tick=tick,
event_type="commerce",
description=f"{char['name']} achète à {target['name']} ({amount} ¢)",
actor_id=char_id,
location_slug=char.get("location_slug"),
mechanical_effect={"caps": amount, "vendeur_id": target["id"]},
)
elif action == "sleep":
import survival as sv_module
sv_module.rest_character(session_id, char_id, day, tick, hours=1)
db.log_event(
session_id=session_id, day=day, tick=tick,
event_type="survie_action",
description=decision["description"],
actor_id=char_id,
location_slug=char.get("location_slug"),
)
elif action in ("forage", "find_water"):
db.log_event(
session_id=session_id, day=day, tick=tick,
event_type="survie_action",
description=decision["description"],
actor_id=char_id,
location_slug=char.get("location_slug"),
)
# idle : pas de log
# ---------------------------------------------------------------------------
# Point d'entrée principal
# ---------------------------------------------------------------------------
def process_actions_tick(session_id: int, day: int, tick: int,
mode: str, phase: str) -> None:
"""
Brique 5 — appelé depuis tick.py après survival.process_survival_tick().
Charge tous les PNJ vivants, décide et exécute leurs actions.
Trie par initiative (PER + AGI) descendant.
"""
characters = db.get_characters(session_id)
if not characters:
return
world_states = db.get_world_state(session_id, day)
world_map = {ws["location_slug"]: ws for ws in world_states}
# Chargement batch états + inventaires
states: dict[int, dict] = {}
inventories: dict[int, list] = {}
for char in characters:
state = db.get_pnj_state(session_id, char["id"], day)
states[char["id"]] = state or {}
inventories[char["id"]] = db.get_inventory(char["id"], session_id)
# Ordre d'initiative (PER + AGI, décroissant)
ordered = sorted(characters, key=lambda c: c["perception"] + c["agility"], reverse=True)
chars_map: dict[int, dict] = {c["id"]: c for c in characters}
for char in ordered:
if not char.get("is_alive", True):
continue
seed = (
states.get(char["id"], {}).get("seed_current")
or char["seed_base"] + day * 100 + tick
)
rng = random.Random(seed)
decision = _decide_action(
char, list(chars_map.values()), states, inventories,
world_map, rng, phase, mode
)
_execute_action(char, decision, chars_map, inventories, session_id, day, tick)
# Mise à jour locale de chars_map si le PNJ est mort (évite une requête DB par PNJ)
if decision["action"] == "attack" and decision.get("target_id"):
target = chars_map.get(decision["target_id"])
if target:
updated_target = db.get_character_by_id(decision["target_id"])
if updated_target:
chars_map[decision["target_id"]] = updated_target
+424
View File
@@ -0,0 +1,424 @@
"""
Brique 3 — Création de personnage
Génère des personnages complets selon Manuel Officiel FR p.42-76.
Formules officielles (Manuel FR) :
max_hp = END + LCK
carry_weight = 75 + (FOR × 5) kg
defense = 1 si AGI 1-8, 2 si AGI 9+
initiative = PER + AGI
melee_bonus = 0 si FOR<7, +1CD si FOR 7-8, +2CD si FOR 9-10, +3CD si FOR 11+
6 Origines officielles (Manuel FR p.48-57) :
survivant, habitant_abri, initie_confrerie, goule, super_mutant, mister_handy
Types de personnage :
- 'pnj_sim' : PNJ simulé par le LLM (faction existante)
- 'encounter' : rencontre aléatoire (sans faction fixe)
- 'pretire' : fiche prête à jouer pour joueur humain
"""
import random
import db
# ---------------------------------------------------------------------------
# Rôles narratifs (influencent répartition SPECIAL) — séparés des Origines
# ---------------------------------------------------------------------------
_ROLES: dict[str, dict] = {
"pillard": {
"description": "Pillard des terres désolées, violent et imprévisible",
"special": {"STR": (5,8), "PER": (4,6), "END": (5,7), "CHA": (3,5), "INT": (3,5), "AGI": (5,7), "LCK": (3,6)},
"faction_pool": ["ecumeurs", None],
"caps": (0, 100),
},
"marchand": {
"description": "Commerçant itinérant, prudent mais bien connecté",
"special": {"STR": (4,6), "PER": (5,7), "END": (4,6), "CHA": (7,9), "INT": (6,8), "AGI": (4,6), "LCK": (5,8)},
"faction_pool": ["consortium", "regie", None],
"caps": (200, 2000),
},
"garde": {
"description": "Milicien ou garde d'une faction, entraîné et discipliné",
"special": {"STR": (6,8), "PER": (5,7), "END": (6,8), "CHA": (4,6), "INT": (4,6), "AGI": (5,7), "LCK": (4,6)},
"faction_pool": ["union", "cda", "syndicat_capitole", "regie"],
"caps": (50, 300),
},
"eclaireur": {
"description": "Rôdeur solitaire des marais, discret et endurant",
"special": {"STR": (5,7), "PER": (7,9), "END": (6,8), "CHA": (3,5), "INT": (5,7), "AGI": (7,9), "LCK": (5,7)},
"faction_pool": [None, "ecumeurs"],
"caps": (30, 200),
},
"docteur": {
"description": "Médecin de terrain, rare et précieux dans les terres désolées",
"special": {"STR": (4,6), "PER": (6,8), "END": (5,7), "CHA": (6,8), "INT": (8,10), "AGI": (5,7), "LCK": (5,7)},
"faction_pool": ["union", "consortium", None],
"caps": (100, 500),
},
"technicien": {
"description": "Bricoleur et réparateur, obsédé par les technologies de l'Ancien Monde",
"special": {"STR": (4,6), "PER": (6,8), "END": (4,6), "CHA": (4,6), "INT": (8,10), "AGI": (6,8), "LCK": (4,7)},
"faction_pool": ["consortium", "union", None],
"caps": (50, 400),
},
"raider_boss": {
"description": "Chef de bande, charismatique et dangereux",
"special": {"STR": (7,9), "PER": (5,7), "END": (6,8), "CHA": (6,8), "INT": (4,6), "AGI": (5,7), "LCK": (5,7)},
"faction_pool": ["ecumeurs", None],
"caps": (100, 1000),
},
"survivant_generique": {
"description": "Habitant lambda des terres désolées, équilibré et débrouillard",
"special": {"STR": (5,7), "PER": (5,7), "END": (5,7), "CHA": (5,7), "INT": (5,7), "AGI": (5,7), "LCK": (5,7)},
"faction_pool": [None, "union", "regie"],
"caps": (10, 200),
},
}
# ---------------------------------------------------------------------------
# 6 Origines officielles (Manuel FR p.48-57)
# Chaque origine applique des modificateurs et traits spéciaux.
# ---------------------------------------------------------------------------
ORIGINS: dict[str, dict] = {
"survivant": {
"label": "Survivant",
"description": "Né dans les terres désolées, endurci par la survie quotidienne",
"special_base": 5, # tous SPECIAL commencent à 5
"special_points": 5, # 5 points à distribuer librement
"traits": ["deux_traits_au_choix"], # 2 traits de la liste officielle
"caps_bonus": 0,
"immune_radiation": False,
"immune_poison": False,
"needs_food": True,
"needs_water": True,
"needs_sleep": True,
},
"habitant_abri": {
"label": "Habitant de l'Abri",
"description": "Élevé dans un Vault, peu habitué aux dangers des terres désolées",
"special_base": 5,
"special_points": 5,
"traits": ["resistance_maladies", "atout_supplementaire"], # résistance maladies + 1 atout bonus
"caps_bonus": 0,
"immune_radiation": False,
"immune_poison": False,
"needs_food": True,
"needs_water": True,
"needs_sleep": True,
},
"initie_confrerie": {
"label": "Initié de la Confrérie",
"description": "Formé par la Confrérie de l'Acier, expert en technologie ancienne",
"special_base": 5,
"special_points": 5,
# Atout supplémentaire dans une compétence Énergie, Science ou Réparation
"traits": ["atout_energie_science_reparation"],
"caps_bonus": 0,
"immune_radiation": False,
"immune_poison": False,
"needs_food": True,
"needs_water": True,
"needs_sleep": True,
},
"goule": {
"label": "Goule",
"description": "Humain transformé par les radiations, immunisé et soigné par la radiation",
"special_base": 5,
"special_points": 5,
"traits": ["immunite_radiation", "soin_par_radiation"],
"caps_bonus": 0,
"immune_radiation": True, # pas de dégâts RAD
"heal_by_radiation": True, # RAD = soin PV
"immune_poison": False,
"needs_food": True,
"needs_water": True,
"needs_sleep": True,
},
"super_mutant": {
"label": "Super Mutant",
"description": "Humain transformé par le FEV, immense force et endurance mais intelligence réduite",
"special_base": 5,
"special_points": 5,
# FOR et END peuvent atteindre 12 ; INT et CHA plafonnent à 6 ; compétences max rang 4
"traits": ["for_end_max12", "int_cha_max6", "skill_max4", "immunite_radiation", "immunite_poison"],
"special_cap_override": {"STR": 12, "END": 12, "INT": 6, "CHA": 6},
"caps_bonus": 0,
"immune_radiation": True,
"immune_poison": True,
"needs_food": True,
"needs_water": True,
"needs_sleep": True,
},
"mister_handy": {
"label": "Mister Handy",
"description": "Robot domestique de l'Ancien Monde, ne mange ni ne boit ni ne dort",
"special_base": 5,
"special_points": 5,
"traits": ["robot_pas_survie"], # pas de faim/soif/sommeil
"caps_bonus": 0,
"immune_radiation": True,
"immune_poison": True,
"needs_food": False,
"needs_water": False,
"needs_sleep": False,
},
}
# Mapping rôle → origine probable (pour génération automatique PNJ)
_ROLE_TO_ORIGIN: dict[str, list[str]] = {
"pillard": ["survivant"],
"marchand": ["survivant", "habitant_abri"],
"garde": ["survivant", "habitant_abri", "initie_confrerie"],
"eclaireur": ["survivant", "goule"],
"docteur": ["survivant", "habitant_abri"],
"technicien": ["initie_confrerie", "habitant_abri", "survivant"],
"raider_boss": ["survivant", "super_mutant"],
"survivant_generique": ["survivant", "habitant_abri"],
}
# ---------------------------------------------------------------------------
# Listes de noms louisianais / post-apo
# ---------------------------------------------------------------------------
_PRENOMS = [
"Beaumont", "Thibodaux", "Fontenot", "Arceneaux", "Boudreaux",
"LaFleur", "Duvall", "Mouton", "Leblanc", "Theriot",
"Hébert", "Broussard", "Trahan", "Guidry", "Landry",
"Gautreau", "Migaud", "Serpas", "Delacambre", "Trosclair",
"Réaux", "Guidroz", "Savoie", "Champagne", "Gaudin",
]
_SURNOMS = [
"Mudcat", "Ironhide", "Deux-Faces", "Bayou", "Crapaud",
"Gator", "Swamp Fox", "Fil-de-Fer", "Sac-à-Feu", "Vieux",
"Marécage", "Rouille", "Tripette", "Gris-Gris", "Lapin",
]
def _roll_special(role: str, origin: str, seed: int | None = None) -> dict[str, int]:
rng = random.Random(seed)
spec = _ROLES[role]["special"]
result = {
"strength": rng.randint(*spec["STR"]),
"perception": rng.randint(*spec["PER"]),
"endurance": rng.randint(*spec["END"]),
"charisma": rng.randint(*spec["CHA"]),
"intelligence": rng.randint(*spec["INT"]),
"agility": rng.randint(*spec["AGI"]),
"luck": rng.randint(*spec["LCK"]),
}
# Appliquer plafonds spéciaux (ex : super_mutant)
caps = ORIGINS[origin].get("special_cap_override", {})
attr_map = {"STR": "strength", "END": "endurance", "INT": "intelligence", "CHA": "charisma"}
for short, full in attr_map.items():
if short in caps:
result[full] = min(result[full], caps[short])
return result
def _derive_stats(special: dict) -> dict:
str_ = special["strength"]
per = special["perception"]
end = special["endurance"]
agi = special["agility"]
lck = special["luck"]
max_hp = end + lck
carry = 75 + str_ * 5 # Manuel FR : 75 + (FOR × 5) kg
defense = 2 if agi >= 9 else 1
initiative = per + agi
melee_bonus = 0 if str_ < 7 else (1 if str_ <= 8 else (2 if str_ <= 10 else 3))
return {
"max_hp": max_hp,
"hp": max_hp,
"carry_weight": carry,
"defense": defense,
"initiative": initiative,
"melee_bonus_cd": melee_bonus,
}
def generate_character(
session_id: int,
role: str = "survivant_generique",
origin: str | None = None,
character_type: str = "encounter",
name: str | None = None,
faction_slug: str | None = None,
location_slug: str | None = None,
seed: int | None = None,
caps: int | None = None,
save_to_db: bool = True,
) -> dict:
"""
Génère un personnage complet.
Args:
session_id : ID de session
role : clé dans _ROLES (rôle narratif)
origin : clé dans ORIGINS (6 origines officielles ; déduit du rôle si None)
character_type : 'pnj_sim' | 'encounter' | 'pretire'
name : nom custom (généré si None)
faction_slug : faction (tirée au sort depuis le rôle si None)
location_slug : lieu de départ (None = non placé)
seed : graine pour reproductibilité
caps : capsules (tiré au sort depuis le rôle si None)
save_to_db : True = INSERT dans characters, False = retourne dict seul
Returns:
dict avec tous les champs du personnage + stats dérivées + infos origine
"""
if role not in _ROLES:
raise ValueError(f"Rôle inconnu: {role}. Disponibles: {list(_ROLES)}")
rng = random.Random(seed)
# Origine
if origin is None:
origin = rng.choice(_ROLE_TO_ORIGIN.get(role, ["survivant"]))
if origin not in ORIGINS:
raise ValueError(f"Origine inconnue: {origin}. Disponibles: {list(ORIGINS)}")
role_data = _ROLES[role]
origin_data = ORIGINS[origin]
# Nom
if name is None:
if origin == "mister_handy":
suffixes = ["Mk.II", "Mk.III", "Delta", "Gamma", "417", "99B"]
name = f"Handy-{rng.choice(suffixes)}"
else:
name = f"{rng.choice(_PRENOMS)} '{rng.choice(_SURNOMS)}'"
# Faction
if faction_slug is None:
faction_slug = rng.choice(role_data["faction_pool"])
# SPECIAL
special = _roll_special(role, origin, seed)
derived = _derive_stats(special)
# Caps
if caps is None:
caps = rng.randint(*role_data["caps"])
seed_base = seed if seed is not None else rng.randint(1000, 99999)
char = {
"session_id": session_id,
"name": name,
"character_type": character_type,
"faction_slug": faction_slug,
"location_slug": location_slug,
"seed_base": seed_base,
"role": role,
"origin": origin,
"description": role_data["description"],
# Traits origine
"needs_food": origin_data["needs_food"],
"needs_water": origin_data["needs_water"],
"needs_sleep": origin_data["needs_sleep"],
"immune_radiation": origin_data["immune_radiation"],
"immune_poison": origin_data["immune_poison"],
# SPECIAL
**special,
# Dérivées
**derived,
"caps": caps,
"rads": 0,
"is_alive": True,
"is_in_shelter": False,
}
if save_to_db:
char_id = _insert_character(char)
char["id"] = char_id
return char
def _insert_character(char: dict) -> int:
"""INSERT le personnage en DB, retourne son id."""
with db.cursor() as cur:
cur.execute(
"""
INSERT INTO characters (
session_id, name, character_type, faction_slug, location_slug, seed_base,
strength, perception, endurance, charisma, intelligence, agility, luck,
hp, max_hp, caps, rads, is_alive, is_in_shelter,
role, origin, needs_food, needs_water, needs_sleep,
immune_radiation, immune_poison
) VALUES (
%(session_id)s, %(name)s, %(character_type)s, %(faction_slug)s, %(location_slug)s, %(seed_base)s,
%(strength)s, %(perception)s, %(endurance)s, %(charisma)s, %(intelligence)s, %(agility)s, %(luck)s,
%(hp)s, %(max_hp)s, %(caps)s, %(rads)s, %(is_alive)s, %(is_in_shelter)s,
%(role)s, %(origin)s, %(needs_food)s, %(needs_water)s, %(needs_sleep)s,
%(immune_radiation)s, %(immune_poison)s
) RETURNING id
""",
char,
)
return cur.fetchone()["id"]
def generate_encounter(session_id: int, location_slug: str,
role: str | None = None, origin: str | None = None,
seed: int | None = None) -> dict:
"""Génère une rencontre aléatoire dans un lieu."""
encounter_roles = ["pillard", "garde", "eclaireur", "raider_boss"]
if role is None:
rng = random.Random(seed)
role = rng.choice(encounter_roles)
return generate_character(
session_id=session_id,
role=role,
origin=origin,
character_type="encounter",
location_slug=location_slug,
seed=seed,
save_to_db=True,
)
def generate_pretire(session_id: int, role: str = "survivant_generique",
origin: str = "survivant", name: str | None = None,
seed: int | None = None) -> dict:
"""Génère une fiche prête à jouer pour un joueur humain (non sauvegardée)."""
return generate_character(
session_id=session_id,
role=role,
origin=origin,
character_type="pretire",
name=name,
seed=seed,
save_to_db=False,
)
def format_sheet(char: dict) -> str:
"""Affiche une fiche personnage lisible en texte."""
origin_label = ORIGINS.get(char.get("origin", ""), {}).get("label", char.get("origin", "?"))
lines = [
f"{'='*50}",
f" {char['name'].upper()}",
f" Rôle : {char.get('role','?')} | {char.get('description','')}",
f" Origine : {origin_label}",
f" Faction : {char.get('faction_slug') or 'Sans faction'}",
f" Lieu : {char.get('location_slug') or ''}",
f"{'='*50}",
f" SPECIAL",
f" FOR:{char['strength']} PER:{char['perception']} END:{char['endurance']}",
f" CHA:{char['charisma']} INT:{char['intelligence']} AGI:{char['agility']} LCK:{char['luck']}",
f"{''*50}",
f" PV : {char['hp']}/{char['max_hp']}",
f" Défense : {char.get('defense','?')}",
f" Init : {char.get('initiative','?')}",
f" Poids max: {char.get('carry_weight','?')} kg",
f" Corps à corps : +{char.get('melee_bonus_cd',0)} CD",
f" Capsules : {char['caps']} ¢",
f" Besoins : {'Nourriture ' if char.get('needs_food') else ''}{'Eau ' if char.get('needs_water') else ''}{'Sommeil' if char.get('needs_sleep') else '— Robot/Goule'}",
f"{'='*50}",
]
return "\n".join(lines)
+17
View File
@@ -0,0 +1,17 @@
import os
# --- PostgreSQL (Vigile) ---
DB_HOST = os.getenv("DB_HOST", "localhost")
DB_PORT = int(os.getenv("DB_PORT", 5432))
DB_NAME = os.getenv("DB_NAME", "fallout")
DB_USER = os.getenv("DB_USER", "fallout")
DB_PASS = os.getenv("DB_PASS", "VeniceOfWasteland2026!")
# --- Simulation ---
TICKS_PER_DAY = 24 # 1 tick = 1 heure in-game
DAY_START_TICK = 6 # 06h00
NIGHT_START_TICK = 20 # 20h00
TICK_SLEEP_SEC = 0 # 0 = bot-speed (pré-sim), sinon secondes entre ticks
# --- Session ---
SESSION_ID = int(os.getenv("SESSION_ID", 1))
+267
View File
@@ -0,0 +1,267 @@
"""
Dashboard de monitoring — Fallout Venice of Wasteland
Lance un serveur HTTP local sur le port 8765
Usage : python dashboard.py
"""
import json
import sys, os
from http.server import HTTPServer, BaseHTTPRequestHandler
from datetime import datetime
sys.path.insert(0, os.path.dirname(__file__))
import db
from config import SESSION_ID, TICKS_PER_DAY, DAY_START_TICK, NIGHT_START_TICK
def get_state() -> dict:
try:
session = db.get_session(SESSION_ID)
day = session["current_day"]
tick = session["current_tick"]
characters = db.get_characters(SESSION_ID)
world = db.get_world_state(SESSION_ID, day)
# Derniers events
with db.cursor() as cur:
cur.execute(
"""SELECT day, tick, event_type, description, created_at
FROM events WHERE session_id=%s
ORDER BY id DESC LIMIT 30""",
(SESSION_ID,)
)
events = [dict(r) for r in cur.fetchall()]
# States PNJ
pnj_states = []
for char in characters:
state = db.get_pnj_state(SESSION_ID, char["id"], day)
pnj_states.append({**char, "state": state})
return {
"ok": True,
"session": {
"id": session["id"],
"name": session["name"],
"day": day,
"tick": tick,
"mode": session["mode"],
"status": session["status"],
"seed": session["seed_global"],
"phase": "JOUR" if DAY_START_TICK <= tick < NIGHT_START_TICK else "NUIT",
},
"world": world,
"characters": pnj_states,
"events": events,
"fetched_at": datetime.now().isoformat(),
}
except Exception as e:
return {"ok": False, "error": str(e)}
HTML = """<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<title>Fallout : Venice of Wasteland — Dashboard</title>
<style>
:root { --green:#4caf50;--yellow:#ffeb3b;--red:#f44336;--bg:#1a1a1a;--card:#252525;--text:#e0e0e0;--dim:#888; }
* { box-sizing:border-box; margin:0; padding:0; }
body { background:var(--bg); color:var(--text); font-family:'Courier New',monospace; font-size:13px; padding:16px; }
h1 { color:var(--green); font-size:18px; letter-spacing:2px; margin-bottom:4px; }
.meta { color:var(--dim); font-size:11px; margin-bottom:16px; }
.grid { display:grid; grid-template-columns:1fr 1fr; gap:12px; margin-bottom:12px; }
.card { background:var(--card); border:1px solid #333; border-radius:4px; padding:12px; }
.card h2 { font-size:12px; letter-spacing:1px; color:var(--green); margin-bottom:10px; text-transform:uppercase; }
/* Session */
.phase-jour { color:var(--yellow); font-size:24px; font-weight:bold; }
.phase-nuit { color:#7986cb; font-size:24px; font-weight:bold; }
.session-row { display:flex; justify-content:space-between; margin:4px 0; }
.label { color:var(--dim); }
/* Barres ressources */
.loc { margin-bottom:10px; }
.loc-name { color:#fff; font-weight:bold; margin-bottom:4px; font-size:12px; }
.bar-row { display:flex; align-items:center; gap:8px; margin:2px 0; }
.bar-label { width:70px; color:var(--dim); font-size:11px; }
.bar-bg { flex:1; background:#333; border-radius:2px; height:10px; }
.bar-fill { height:10px; border-radius:2px; transition:width .5s; }
.bar-val { width:35px; text-align:right; font-size:11px; color:var(--dim); }
/* PNJ */
.pnj { margin-bottom:8px; border-bottom:1px solid #333; padding-bottom:8px; }
.pnj-name { font-weight:bold; color:#fff; }
.pnj-mood { display:inline-block; padding:1px 6px; border-radius:3px; font-size:10px; margin-left:6px; }
.mood-agressif { background:#c62828; }
.mood-defensif { background:#1565c0; }
.mood-neutre { background:#424242; }
.mood-opportuniste { background:#6a1b9a; }
.mood-craintif { background:#4e342e; }
.mood-diplomatique { background:#1b5e20; }
.pnj-row { color:var(--dim); font-size:11px; margin-top:2px; }
.hp-bar { display:inline-block; width:80px; height:6px; background:#333; border-radius:2px; vertical-align:middle; margin:0 4px; }
.hp-fill { height:6px; border-radius:2px; background:var(--green); }
/* Events */
.events { grid-column:1/-1; }
.event-list { max-height:220px; overflow-y:auto; }
.event { padding:4px 0; border-bottom:1px solid #2a2a2a; font-size:11px; }
.event-time { color:var(--green); margin-right:8px; }
.event-type { color:var(--yellow); margin-right:8px; }
/* Status */
.status-ok { color:var(--green); }
.status-err { color:var(--red); }
.refresh { color:var(--dim); font-size:10px; margin-top:12px; text-align:center; }
.mode-pacifique { color:#4fc3f7; }
.mode-normal { color:var(--yellow); }
</style>
</head>
<body>
<h1>☢ FALLOUT : VENICE OF WASTELAND</h1>
<div class="meta" id="meta">Chargement...</div>
<div class="grid">
<div class="card" id="card-session"><h2>Session</h2><div id="session-content">—</div></div>
<div class="card" id="card-pnj"><h2>PNJ Simulés</h2><div id="pnj-content">—</div></div>
<div class="card" id="card-world"><h2>État du Monde</h2><div id="world-content">—</div></div>
<div class="card events" id="card-events"><h2>Journal des Events</h2><div class="event-list" id="events-content">—</div></div>
</div>
<div class="refresh">Rafraîchissement automatique toutes les 3s</div>
<script>
function barColor(v) {
if (v >= 60) return '#4caf50';
if (v >= 30) return '#ffeb3b';
return '#f44336';
}
function renderBar(label, value) {
const v = Math.round(value);
return `<div class="bar-row">
<span class="bar-label">${label}</span>
<div class="bar-bg"><div class="bar-fill" style="width:${v}%;background:${barColor(v)}"></div></div>
<span class="bar-val">${v}</span>
</div>`;
}
const HUNGER = {4:'repu',3:'rassasié',2:'petit creux',1:'faim',0:'affamé'};
const THIRST = {3:'désaltéré',2:'hydraté',1:'soif',0:'déshydraté'};
const SLEEP = {3:'reposé',2:'fatigué',1:'éreinté',0:'épuisé'};
function render(data) {
document.getElementById('meta').innerHTML =
`Mis à jour : ${data.fetched_at ? data.fetched_at.replace('T',' ').substring(0,19) : ''}
| <span class="${data.ok ? 'status-ok':'status-err'}">${data.ok ? '● CONNECTÉ' : '✖ ERREUR DB'}</span>`;
if (!data.ok) {
document.getElementById('session-content').innerHTML = `<span style="color:red">${data.error}</span>`;
return;
}
// -- Session --
const s = data.session;
const phaseClass = s.phase === 'JOUR' ? 'phase-jour' : 'phase-nuit';
const phaseIcon = s.phase === 'JOUR' ? '' : '🌙';
document.getElementById('session-content').innerHTML = `
<div style="text-align:center;margin-bottom:10px">
<span class="${phaseClass}">${phaseIcon} ${s.phase}</span>
</div>
<div class="session-row"><span class="label">Jour</span><span>${s.day}</span></div>
<div class="session-row"><span class="label">Tick</span><span>${String(s.tick).padStart(2,'0')}h00</span></div>
<div class="session-row"><span class="label">Mode</span><span class="mode-${s.mode}">${s.mode.toUpperCase()}</span></div>
<div class="session-row"><span class="label">Status</span><span>${s.status}</span></div>
<div class="session-row"><span class="label">Seed</span><span>${s.seed}</span></div>
<div class="session-row"><span class="label">Session</span><span style="color:#888;font-size:10px">#${s.id}</span></div>
`;
// -- PNJ --
const pnjHtml = (data.characters || []).map(c => {
const st = c.state || {};
const sv = (st.last_actions && !Array.isArray(st.last_actions)) ? st.last_actions : {};
const mood = st.mood || 'neutre';
const hp_pct = Math.round((c.hp / c.max_hp) * 100);
return `<div class="pnj">
<div><span class="pnj-name">${c.name}</span>
<span class="pnj-mood mood-${mood}">${mood}</span>
${c.is_in_shelter ? '<span style="color:#4fc3f7;font-size:10px;margin-left:6px">ABRI</span>' : ''}
</div>
<div class="pnj-row">
HP <div class="hp-bar"><div class="hp-fill" style="width:${hp_pct}%"></div></div>${c.hp}/${c.max_hp}
| Caps: ${c.caps}
| Stress: ${st.stress_level ?? ''}
| Fatigue: ${sv.fatigue ?? 0}
</div>
<div class="pnj-row" style="color:#777;font-size:10px">
🍖${HUNGER[sv.hunger??4]} 💧${THIRST[sv.thirst??3]} 😴${SLEEP[sv.sleep??3]}
</div>
<div class="pnj-row" style="color:#555">${c.location_slug || ''} · ${c.faction_slug || ''}</div>
</div>`;
}).join('') || '<span style="color:#555">Aucun PNJ</span>';
document.getElementById('pnj-content').innerHTML = pnjHtml;
// -- World --
const worldHtml = (data.world || []).map(w =>
`<div class="loc">
<div class="loc-name">${w.location_slug} <span style="color:#555;font-size:10px">pop:${w.pop_count}</span></div>
${renderBar('Eau', w.water_level)}
${renderBar('Nourriture', w.food_level)}
${renderBar('Sécurité', w.security_level)}
</div>`
).join('') || '<span style="color:#555">Aucune donnée</span>';
document.getElementById('world-content').innerHTML = worldHtml;
// -- Events --
const evHtml = (data.events || []).map(e =>
`<div class="event">
<span class="event-time">J${String(e.day).padStart(3,'0')} T${String(e.tick).padStart(2,'0')}h</span>
<span class="event-type">[${e.event_type}]</span>
${e.description}
</div>`
).join('') || '<span style="color:#555">Aucun event</span>';
document.getElementById('events-content').innerHTML = evHtml;
}
async function refresh() {
try {
const r = await fetch('/api/state');
const data = await r.json();
render(data);
} catch(e) {
document.getElementById('meta').innerHTML = `<span class="status-err">✖ Serveur dashboard inaccessible</span>`;
}
}
refresh();
setInterval(refresh, 3000);
</script>
</body>
</html>
"""
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/api/state":
data = json.dumps(get_state(), default=str).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", len(data))
self.end_headers()
self.wfile.write(data)
elif self.path in ("/", "/index.html"):
page = HTML.encode()
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", len(page))
self.end_headers()
self.wfile.write(page)
else:
self.send_response(404)
self.end_headers()
def log_message(self, *args):
pass # silence les logs HTTP
if __name__ == "__main__":
port = int(os.getenv("DASHBOARD_PORT", 8765))
print(f"Dashboard → http://localhost:{port}")
HTTPServer(("0.0.0.0", port), Handler).serve_forever()
+199
View File
@@ -0,0 +1,199 @@
import psycopg2
import psycopg2.extras
from contextlib import contextmanager
from config import DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASS
_conn: "psycopg2.extensions.connection | None" = None
def _get_conn():
global _conn
if _conn is None or _conn.closed:
_conn = psycopg2.connect(
host=DB_HOST, port=DB_PORT,
dbname=DB_NAME, user=DB_USER, password=DB_PASS
)
_conn.autocommit = True
return _conn
@contextmanager
def cursor():
conn = _get_conn()
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
yield cur
# --- Sessions ---
def get_session(session_id: int) -> dict:
with cursor() as cur:
cur.execute("SELECT * FROM sessions WHERE id = %s", (session_id,))
return dict(cur.fetchone())
def update_session_tick(session_id: int, day: int, tick: int):
with cursor() as cur:
cur.execute(
"UPDATE sessions SET current_day=%s, current_tick=%s WHERE id=%s",
(day, tick, session_id)
)
# --- Personnages ---
def get_characters(session_id: int) -> list[dict]:
with cursor() as cur:
cur.execute(
"SELECT * FROM characters WHERE session_id=%s AND is_alive=TRUE",
(session_id,)
)
return [dict(r) for r in cur.fetchall()]
def get_character_by_id(character_id: int) -> dict | None:
with cursor() as cur:
cur.execute("SELECT * FROM characters WHERE id=%s", (character_id,))
row = cur.fetchone()
return dict(row) if row else None
def update_character(character_id: int, **fields):
if not fields:
return
set_clause = ", ".join(f"{k}=%s" for k in fields)
values = list(fields.values()) + [character_id]
with cursor() as cur:
cur.execute(
f"UPDATE characters SET {set_clause} WHERE id=%s",
values
)
# --- World state ---
def get_world_state(session_id: int, day: int) -> list[dict]:
with cursor() as cur:
cur.execute(
"SELECT * FROM world_state WHERE session_id=%s AND day=%s",
(session_id, day)
)
return [dict(r) for r in cur.fetchall()]
def upsert_world_state(session_id: int, location_slug: str, day: int, **fields):
cols = ["session_id", "location_slug", "day"] + list(fields.keys())
vals = [session_id, location_slug, day] + list(fields.values())
update_set = ", ".join(f"{k}=EXCLUDED.{k}" for k in fields)
with cursor() as cur:
cur.execute(
f"""
INSERT INTO world_state ({', '.join(cols)}) VALUES ({', '.join(['%s']*len(vals))})
ON CONFLICT (session_id, location_slug, day) DO UPDATE SET {update_set}
""",
vals
)
# --- Events ---
def log_event(session_id: int, day: int, tick: int, event_type: str,
description: str, actor_id=None, location_slug=None,
mechanical_effect=None):
with cursor() as cur:
cur.execute(
"""
INSERT INTO events
(session_id, day, tick, event_type, actor_id, location_slug,
description, mechanical_effect)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s)
""",
(session_id, day, tick, event_type, actor_id, location_slug,
description, psycopg2.extras.Json(mechanical_effect) if mechanical_effect else None)
)
# --- PNJ state ---
def get_pnj_state(session_id: int, character_id: int, day: int) -> dict | None:
with cursor() as cur:
cur.execute(
"""
SELECT * FROM pnj_state
WHERE session_id=%s AND character_id=%s AND day=%s
ORDER BY tick DESC LIMIT 1
""",
(session_id, character_id, day)
)
row = cur.fetchone()
return dict(row) if row else None
# --- Inventory ---
def get_inventory(character_id: int, session_id: int) -> list[dict]:
with cursor() as cur:
cur.execute(
"SELECT * FROM inventory WHERE character_id=%s AND session_id=%s ORDER BY item_type, item_name",
(character_id, session_id)
)
return [dict(r) for r in cur.fetchall()]
def add_item(character_id: int, session_id: int, item_slug: str, item_name: str,
item_type: str, quantity: int = 1, weight: float = 0, **props):
with cursor() as cur:
# Si l'item existe déjà (même slug), on ajoute la quantité
cur.execute(
"SELECT id, quantity FROM inventory WHERE character_id=%s AND session_id=%s AND item_slug=%s",
(character_id, session_id, item_slug)
)
row = cur.fetchone()
if row:
cur.execute("UPDATE inventory SET quantity=%s WHERE id=%s",
(row["quantity"] + quantity, row["id"]))
else:
import psycopg2.extras as _extras
cols = ["character_id","session_id","item_slug","item_name","item_type","quantity","weight"]
vals = [character_id, session_id, item_slug, item_name, item_type, quantity, weight]
for k, v in props.items():
cols.append(k)
vals.append(_extras.Json(v) if isinstance(v, dict) else v)
cur.execute(
f"INSERT INTO inventory ({','.join(cols)}) VALUES ({','.join(['%s']*len(vals))})",
vals
)
def remove_item(character_id: int, session_id: int, item_slug: str, quantity: int = 1) -> bool:
"""Retire quantity unités. Retourne True si retiré, False si pas assez."""
with cursor() as cur:
cur.execute(
"SELECT id, quantity FROM inventory WHERE character_id=%s AND session_id=%s AND item_slug=%s",
(character_id, session_id, item_slug)
)
row = cur.fetchone()
if not row or row["quantity"] < quantity:
return False
if row["quantity"] == quantity:
cur.execute("DELETE FROM inventory WHERE id=%s", (row["id"],))
else:
cur.execute("UPDATE inventory SET quantity=%s WHERE id=%s",
(row["quantity"] - quantity, row["id"]))
return True
def upsert_pnj_state(session_id: int, character_id: int, day: int, tick: int, **fields):
cols = ["session_id", "character_id", "day", "tick"] + list(fields.keys())
raw_vals = [session_id, character_id, day, tick] + list(fields.values())
vals = [psycopg2.extras.Json(v) if isinstance(v, (dict, list)) else v for v in raw_vals]
update_set = ", ".join(f"{k}=EXCLUDED.{k}" for k in fields)
with cursor() as cur:
cur.execute(
f"""
INSERT INTO pnj_state ({', '.join(cols)}) VALUES ({', '.join(['%s']*len(vals))})
ON CONFLICT (session_id, character_id, day, tick) DO UPDATE SET {update_set}
""",
vals
)
+54
View File
@@ -0,0 +1,54 @@
"""
Script one-shot : crée 10 PNJ de test en DB pour la pré-sim 3 jours pacifique.
Lance avec : python init_pnj.py
"""
import sys, os
sys.path.insert(0, os.path.dirname(__file__))
import character_creator as cc
import db
SESSION_ID = 1
PNJ_SPECS = [
# (role, origin, name, faction, location)
("garde", "survivant", "Thibodaux 'Gator'", "union", "nola_cbd"),
("garde", "initie_confrerie","Fontenot 'Ironhide'", "cda", "nola_cbd"),
("marchand", "habitant_abri", "LaFleur 'Vieux'", "consortium", "nola_vieux_carre"),
("marchand", "survivant", "Broussard 'Fil-de-Fer'", "regie", "nola_vieux_carre"),
("eclaireur", "goule", "Mouton 'Marécage'", None, "pearl_river"),
("eclaireur", "survivant", "Trahan 'Mudcat'", "union", "pearl_river"),
("docteur", "habitant_abri", "Hébert 'Tripette'", "union", "nola_cbd"),
("technicien", "initie_confrerie","Landry 'Rouille'", "consortium", "baton_rouge"),
("pillard", "survivant", "Arceneaux 'Crapaud'", "ecumeurs", "pearl_river"),
("survivant_generique","survivant", "Guidry 'Gris-Gris'", None, "nola_vieux_carre"),
]
def main():
print(f"=== Création de {len(PNJ_SPECS)} PNJ — Session {SESSION_ID} ===\n")
created = []
for i, (role, origin, name, faction, location) in enumerate(PNJ_SPECS, 1):
char = cc.generate_character(
session_id=SESSION_ID,
role=role,
origin=origin,
name=name,
faction_slug=faction,
location_slug=location,
character_type="pnj_sim",
seed=1000 + i,
save_to_db=True,
)
created.append(char)
print(cc.format_sheet(char))
print()
print(f"{len(created)} PNJ insérés en DB (session {SESSION_ID})")
# Vérification rapide
chars = db.get_characters(SESSION_ID)
print(f" DB confirme : {len(chars)} PNJ vivants en session {SESSION_ID}")
if __name__ == "__main__":
main()
+130
View File
@@ -0,0 +1,130 @@
"""
Brique 4 — Équipement / Inventaire
Gestion du poids, consommation d'items, équipement armure/armes.
Carry Weight = 150 + (STR × 10) lbs — Rules Booklet p.12
Consommation nourriture/eau → relie à survival.py (feed/hydrate).
"""
import db
import survival
def get_carry_weight(strength: int) -> int:
# Manuel FR p.52 : 75 + (FOR × 5) kg
return 75 + strength * 5
def get_current_weight(character_id: int, session_id: int) -> float:
items = db.get_inventory(character_id, session_id)
return sum(i["weight"] * i["quantity"] for i in items)
def is_overencumbered(char: dict) -> bool:
max_w = get_carry_weight(char["strength"])
current_w = get_current_weight(char["id"], char["session_id"])
return current_w > max_w
def consume_food(session_id: int, char_id: int, day: int, tick: int,
item_slug: str) -> dict:
"""
Consomme 1 unité de nourriture depuis l'inventaire.
Retourne {"ok": bool, "reason": str, "effect": dict}
"""
items = db.get_inventory(char_id, session_id)
item = next((i for i in items if i["item_slug"] == item_slug and i["item_type"] == "food"), None)
if not item:
return {"ok": False, "reason": f"Item '{item_slug}' absent de l'inventaire"}
if item["quantity"] < 1:
return {"ok": False, "reason": "Quantité insuffisante"}
# Type de nourriture : cooked si hp_heal >= 7 (nourriture cuisinée), soup si slug contient 'soupe'
food_type = "normal"
if "soupe" in item_slug or "bouillon" in item_slug:
food_type = "soup"
elif item.get("hp_heal", 0) >= 7:
food_type = "cooked"
db.remove_item(char_id, session_id, item_slug, 1)
survival.feed_character(session_id, char_id, day, tick, food_type=food_type)
# Soins PV
hp_heal = item.get("hp_heal", 0)
if hp_heal > 0:
chars = [c for c in db.get_characters(session_id) if c["id"] == char_id]
if chars:
char = chars[0]
new_hp = min(char["max_hp"], char["hp"] + hp_heal)
db.update_character(char_id, hp=new_hp)
# Radiations
rad_gain = 0
props = item.get("properties") or {}
if isinstance(props, dict) and props.get("irradiated"):
rad_gain = props.get("rad_on_eat", 1)
chars = [c for c in db.get_characters(session_id) if c["id"] == char_id]
if chars:
new_rads = chars[0]["rads"] + rad_gain
db.update_character(char_id, rads=new_rads)
db.log_event(
session_id=session_id, day=day, tick=tick,
event_type="inventaire",
description=f"Consomme {item['item_name']} (food_type={food_type}, +{hp_heal}PV, +{rad_gain}RAD)",
actor_id=char_id,
mechanical_effect={"item": item_slug, "hp_heal": hp_heal, "rad": rad_gain},
)
return {"ok": True, "reason": "ok", "effect": {"hp_heal": hp_heal, "rad_gain": rad_gain, "food_type": food_type}}
def consume_drink(session_id: int, char_id: int, day: int, tick: int,
item_slug: str) -> dict:
"""Consomme 1 boisson depuis l'inventaire."""
items = db.get_inventory(char_id, session_id)
item = next((i for i in items if i["item_slug"] == item_slug and i["item_type"] == "drink"), None)
if not item:
return {"ok": False, "reason": f"Item '{item_slug}' absent de l'inventaire"}
water_type = "purified" if "purifie" in item_slug or "purif" in item_slug else "normal"
db.remove_item(char_id, session_id, item_slug, 1)
survival.hydrate_character(session_id, char_id, day, tick, water_type=water_type)
hp_heal = item.get("hp_heal", 0)
if hp_heal > 0:
chars = [c for c in db.get_characters(session_id) if c["id"] == char_id]
if chars:
new_hp = min(chars[0]["max_hp"], chars[0]["hp"] + hp_heal)
db.update_character(char_id, hp=new_hp)
props = item.get("properties") or {}
rad_gain = 0
if isinstance(props, dict) and props.get("irradiated"):
rad_gain = props.get("rad_on_drink", 1)
chars = [c for c in db.get_characters(session_id) if c["id"] == char_id]
if chars:
db.update_character(char_id, rads=chars[0]["rads"] + rad_gain)
db.log_event(
session_id=session_id, day=day, tick=tick,
event_type="inventaire",
description=f"Boit {item['item_name']} (water_type={water_type}, +{hp_heal}PV, +{rad_gain}RAD)",
actor_id=char_id,
mechanical_effect={"item": item_slug, "hp_heal": hp_heal, "rad": rad_gain},
)
return {"ok": True, "reason": "ok", "effect": {"hp_heal": hp_heal, "rad_gain": rad_gain}}
def inventory_summary(character_id: int, session_id: int, char: dict) -> str:
"""Résumé texte de l'inventaire pour le MJ LLM."""
items = db.get_inventory(character_id, session_id)
max_w = get_carry_weight(char["strength"])
cur_w = sum(i["weight"] * i["quantity"] for i in items)
lines = [f"Inventaire de {char['name']} ({cur_w:.1f}/{max_w} kg)"]
for i in items:
eq = " [ÉQUIPÉ]" if i.get("is_equipped") else ""
lines.append(f" {i['item_name']} ×{i['quantity']} ({i['item_type']}){eq}")
return "\n".join(lines)
+231
View File
@@ -0,0 +1,231 @@
"""
Brique 5 — Couche LLM
- RAG : ChromaDB fallout_lore (REST v2 sur Ampère :8800)
- MJ : qwen2.5:14b (Ollama :11434) — narration, arbitrage, événements
- PNJ : qwen2.5:7b (Ollama :11434) — décisions individuelles des personnages
"""
import json
import os
import urllib.request
import urllib.error
# ── Config ────────────────────────────────────────────────────────────────────
OLLAMA_URL = "http://localhost:11434"
CHROMA_BASE = "http://localhost:8800/api/v2/tenants/default_tenant/databases/default_database"
COLLECTION = "fallout_lore"
COLLECTION_ID = "d153d678-be5b-4f07-88e4-00dbaea5dd4e"
MODEL_MJ = "qwen2.5:14b"
MODEL_PNJ = "qwen2.5:7b"
RAG_TOP_K = 4
RAG_FILTER_DEFAULT = {"systeme": {"$in": ["2d20", "none"]}}
# Répertoire des prompts (relatif à src/)
_HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PROMPTS_DIR = os.path.join(_HERE, "prompts")
def _load_pnj_profiles() -> dict:
path = os.path.join(PROMPTS_DIR, "pnj_profiles.json")
with open(path, encoding="utf-8") as f:
return json.load(f)
def _load_prompt(name: str) -> str:
with open(os.path.join(PROMPTS_DIR, name), encoding="utf-8") as f:
return f.read()
PNJ_PROFILES: dict = {} # chargé à la demande
# ── HTTP helper ────────────────────────────────────────────────────────────────
def _http(method: str, url: str, payload: dict | None = None, timeout: int = 120) -> dict:
data = json.dumps(payload).encode() if payload else None
req = urllib.request.Request(url, data=data, method=method,
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.loads(r.read())
# ── RAG ───────────────────────────────────────────────────────────────────────
def _embed(text: str) -> list[float]:
safe = " ".join(text.split()[:280])
res = _http("POST", f"{OLLAMA_URL}/api/embed",
{"model": "nomic-embed-text", "input": [safe]})
return res["embeddings"][0]
def query_rag(question: str, where: dict | None = None, n: int = RAG_TOP_K) -> list[str]:
"""Retourne n chunks de règles pertinents depuis ChromaDB."""
vec = _embed(question)
where = where or RAG_FILTER_DEFAULT
body = {
"query_embeddings": [vec],
"n_results": n,
"where": where,
"include": ["documents"]
}
res = _http("POST", f"{CHROMA_BASE}/collections/{COLLECTION_ID}/query", body, timeout=60)
docs = res.get("documents", [[]])[0]
return docs
# ── Contexte PNJ depuis DB ────────────────────────────────────────────────────
def build_pnj_context(pnj: dict, pnj_state: dict | None, faction_name: str,
location_name: str, inventory: list[dict]) -> str:
"""Formate l'état DB d'un PNJ en texte injecté dans le prompt."""
lines = [
f"Nom : {pnj['name']}",
f"Faction : {faction_name}",
f"Localisation actuelle : {location_name}",
f"HP : {pnj_state['hp']}/{pnj['max_hp']}" if pnj_state else f"HP max : {pnj['max_hp']}",
f"Humeur : {pnj_state['mood'] if pnj_state else 'inconnue'}",
f"Stress : {pnj_state['stress_level'] if pnj_state else 0}/10",
]
if inventory:
items = ", ".join(f"{i['item_name']} x{i['quantity']}" for i in inventory[:6])
lines.append(f"Inventaire : {items}")
traits = pnj.get("traits") or []
if traits:
lines.append(f"Traits : {', '.join(traits)}")
return "\n".join(lines)
# ── Appels LLM ────────────────────────────────────────────────────────────────
def _generate(model: str, system: str, user: str, temperature: float = 0.7) -> str:
payload = {
"model": model,
"prompt": user,
"system": system,
"stream": False,
"options": {"temperature": temperature, "num_predict": 512}
}
res = _http("POST", f"{OLLAMA_URL}/api/generate", payload, timeout=180)
return res.get("response", "").strip()
def ask_mj(situation: str, rag_context: list[str] | None = None,
temperature: float = 0.7) -> str:
"""
Interroge le MJ (14B) : narration d'événement, arbitrage de règle,
décision globale sur la session.
"""
rules_block = ""
if rag_context:
rules_block = "\n\n## Règles applicables\n" + "\n---\n".join(rag_context)
system = (
"Tu es le Maître de Jeu de la campagne Fallout 2D20 'Venice of Wasteland' "
"se déroulant en Louisiane post-apocalyptique. "
"Tu arbitres les actions des PNJ autonomes, génères les événements du monde "
"et maintiens la cohérence narrative. "
"Réponds en français, de façon concise et immersive. "
"Format de réponse : description narrative courte (2-4 phrases) + "
"effet mécanique JSON sur une ligne séparée.\n"
"Exemple effet : {\"type\": \"world_event\", \"location\": \"nola_vieux_carre\", "
"\"security_delta\": -10, \"description_courte\": \"Fusillade au carré\"}"
+ rules_block
)
return _generate(MODEL_MJ, system, situation, temperature)
def ask_pnj(pnj_name: str, pnj_db_context: str,
day: int, tick: int, phase: str,
world_summary: str = "", last_actions: str = "Aucune action récente.",
rag_context: list[str] | None = None,
temperature: float = 0.8) -> str:
"""
Interroge un PNJ (7B) pour décider son action du tick.
Retourne texte narratif + JSON d'action sur une ligne.
"""
global PNJ_PROFILES
if not PNJ_PROFILES:
PNJ_PROFILES = _load_pnj_profiles()
profile = PNJ_PROFILES.get(pnj_name, {})
template = _load_prompt("pnj_action_prompt.txt")
faction_labels = {
"syndicat_capitole": "chef du Syndicat du Capitole",
"dynaste_oak": "patriarche de la Dynaste d'Oak",
"grand_krewe": "Mambo du Grand Krewe",
"union": "Commissaire de l'Union",
"consortium": "Directeur du Consortium",
}
faction_slug = profile.get("faction", "")
rules_block = ""
if rag_context:
rules_block = "\n## Règles pertinentes\n" + "\n---\n".join(rag_context[:2])
user_prompt = template.format(
pnj_name=pnj_name,
faction_label=faction_labels.get(faction_slug, faction_slug),
personnalite=profile.get("personnalite", ""),
objectif_court=profile.get("objectif_court", ""),
objectif_long=profile.get("objectif_long", ""),
peur=profile.get("peur", ""),
day=day,
heure=f"{tick:02d}",
phase=phase,
pnj_context=pnj_db_context,
world_summary=world_summary or "Rien de notable signalé.",
last_actions=last_actions,
) + rules_block
system = (
"Tu es un personnage non-joueur autonome dans Fallout 2D20 'Venice of Wasteland'. "
"Réponds TOUJOURS en français. Format strict : pensée courte (1-2 phrases) "
"puis action JSON sur une seule ligne finale."
)
return _generate(MODEL_PNJ, system, user_prompt, temperature)
# ── Parsing réponse LLM ───────────────────────────────────────────────────────
def parse_action_json(llm_response: str) -> dict | None:
"""
Extrait le premier bloc JSON valide de la réponse LLM.
Gère les balises markdown ```json ... ```, les espaces parasites et
les objets multi-lignes que les modèles Qwen produisent fréquemment.
"""
import re
# Cherche le premier { et le dernier } correspondant (bloc complet)
match = re.search(r"\{.*\}", llm_response, re.DOTALL)
if match:
try:
return json.loads(match.group(0))
except json.JSONDecodeError:
pass
return None
# ── Test rapide ───────────────────────────────────────────────────────────────
if __name__ == "__main__":
print("=== Test RAG ===")
chunks = query_rag("Comment fonctionne le combat dans Fallout 2D20 ?")
for i, c in enumerate(chunks, 1):
print(f"[{i}] {c[:120]}...")
print("\n=== Test MJ ===")
rep_mj = ask_mj(
"Le Syndicat du Capitole vient de bloquer les routes menant à Baton Rouge. "
"Que se passe-t-il dans la région ?",
rag_context=chunks[:2]
)
print(rep_mj)
print(f"Effet extrait : {parse_action_json(rep_mj)}")
print("\n=== Test PNJ (Kingfish Robichaux) ===")
pnj_ctx = ("Nom : Kingfish Robichaux\nFaction : Syndicat du Capitole\n"
"Localisation actuelle : nola_cbd\nHP : 12/12\nHumeur : confiant\nStress : 2/10\n"
"Inventaire : Pistolet 10mm x1, Cram x3, Eau sale x2")
rep_pnj = ask_pnj(
pnj_name="Kingfish Robichaux",
pnj_db_context=pnj_ctx,
day=1, tick=9, phase="jour",
world_summary="Les routes vers Baton Rouge sont bloquées. Tension +10 avec l'Union.",
last_actions="Tick 8h : wait (observation de la situation)",
rag_context=chunks[:1]
)
print(rep_pnj)
print(f"Action extraite : {parse_action_json(rep_pnj)}")
+1
View File
@@ -0,0 +1 @@
psycopg2-binary>=2.9
+55
View File
@@ -0,0 +1,55 @@
"""
Lanceur de simulation — Fallout Venice of Wasteland
Usage :
python run.py # session 1, bot-speed
SESSION_ID=1 python run.py
TICK_SLEEP_SEC=2 python run.py # 1 tick toutes les 2 secondes
"""
import time
import sys
import os
# Ajoute src/engine au path
sys.path.insert(0, os.path.dirname(__file__))
import db
import tick as tick_engine
from config import SESSION_ID, TICK_SLEEP_SEC
def run():
session = db.get_session(SESSION_ID)
day = session["current_day"]
current_tick = session["current_tick"]
mode = session["mode"]
status = session["status"]
if status != "active":
print(f"Session {SESSION_ID} n'est pas active (status={status}). Abandon.")
return
print(f"=== Simulation Fallout : Venice of Wasteland ===")
print(f"Session #{SESSION_ID}{session['name']}")
print(f"Mode : {mode} | Départ : Jour {day}, Tick {current_tick}h")
print(f"Seed globale : {session['seed_global']}")
print("=" * 50)
try:
while True:
tick_engine.process_tick(SESSION_ID, day, current_tick, mode)
db.update_session_tick(SESSION_ID, day, current_tick)
if TICK_SLEEP_SEC > 0:
time.sleep(TICK_SLEEP_SEC)
day, current_tick = tick_engine.next_tick(day, current_tick)
except KeyboardInterrupt:
print(f"\n[STOP] Simulation interrompue — Jour {day}, Tick {current_tick}h")
db.update_session_tick(SESSION_ID, day, current_tick)
print("État sauvegardé en DB.")
if __name__ == "__main__":
run()
+276
View File
@@ -0,0 +1,276 @@
"""
Brique 2 — Survie
Implémente faim, soif, sommeil et fatigue selon Fallout 2D20 Manuel FR ch.5 (p.190-194).
Échelles (valeurs numériques, plus haut = mieux) :
Faim : 4=Repu, 3=Rassasié, 2=Petit creux, 1=Faim, 0=Affamé
Soif : 3=Désaltéré, 2=Hydraté, 1=Soif, 0=Déshydraté
Sommeil : 3=Reposé, 2=Fatigué, 1=Éreinté, 0=Épuisé
Tick = 1 heure.
Fatigue : accumulée, réduit PA et inflige -2 PV par tranche de 2 pts au début de chaque scène.
"""
import db
import psycopg2.extras
# Heures entre dégradations d'état (conditions normales, mode normal)
HUNGER_TICKS = {
# de Repu(4) → Rassasié(3) après 1h
4: 1,
# de Rassasié(3) → Petit creux(2) après 4h
3: 4,
# de Petit creux(2) → Faim(1) après 8h
2: 8,
# de Faim(1) → Affamé(0) après 16h → +1 Fatigue/jour = +1 toutes les 24h depuis Faim
1: 16,
}
THIRST_TICKS = {
# de Désaltéré(3) → Hydraté(2) après 1h
3: 1,
# de Hydraté(2) → Soif(1) après 2h
2: 2,
# de Soif(1) → Déshydraté(0) après 4h → +1 Fatigue/8h
1: 4,
}
SLEEP_TICKS = {
# de Reposé(3) → Fatigué(2) après 8h
3: 8,
# de Fatigué(2) → Éreinté(1) après 8h
2: 8,
# de Éreinté(1) → Épuisé(0) après 8h
1: 8,
}
# Noms lisibles
HUNGER_NAMES = {4: "repu", 3: "rassasié", 2: "petit_creux", 1: "faim", 0: "affamé"}
THIRST_NAMES = {3: "désaltéré", 2: "hydraté", 1: "soif", 0: "déshydraté"}
SLEEP_NAMES = {3: "reposé", 2: "fatigué", 1: "éreinté", 0: "épuisé"}
def _get_survival_state(pnj_state: dict) -> dict:
"""Extrait ou initialise l'état de survie depuis last_actions (champ JSONB libre)."""
last = pnj_state.get("last_actions") or {}
if isinstance(last, list):
last = {}
return {
"hunger": last.get("hunger", 4),
"thirst": last.get("thirst", 3),
"sleep": last.get("sleep", 3),
"fatigue": last.get("fatigue", 0),
"hunger_ticks": last.get("hunger_ticks", 0),
"thirst_ticks": last.get("thirst_ticks", 0),
"sleep_ticks": last.get("sleep_ticks", 0),
"dehydrated_ticks": last.get("dehydrated_ticks", 0),
}
def _tick_stat(current: int, ticks_spent: int, thresholds: dict, min_val: int = 0) -> tuple[int, int]:
"""
Incrémente ticks_spent, dégrade current si le seuil est atteint.
Retourne (new_current, new_ticks_spent).
"""
if current <= min_val:
return current, ticks_spent
threshold = thresholds.get(current)
if threshold is None:
return current, ticks_spent
ticks_spent += 1
if ticks_spent >= threshold:
return current - 1, 0
return current, ticks_spent
def process_survival_tick(session_id: int, day: int, tick: int):
"""
Appelé à chaque tick (1h).
Met à jour faim/soif/sommeil/fatigue de chaque PNJ vivant.
Tue les PNJ dont les PV tombent à 0 ou en dessous.
"""
characters = db.get_characters(session_id)
for char in characters:
char_id = char["id"]
pnj_state = db.get_pnj_state(session_id, char_id, day) or {}
sv = _get_survival_state(pnj_state)
fatigue_gain = 0
# --- Faim ---
new_hunger, new_hunger_ticks = _tick_stat(
sv["hunger"], sv["hunger_ticks"], HUNGER_TICKS
)
# +1 Fatigue par jour si Affamé (tick 0 de chaque jour = tick 0)
if new_hunger == 0 and tick == 0:
fatigue_gain += 1
sv["hunger"] = new_hunger
sv["hunger_ticks"] = new_hunger_ticks
# --- Soif ---
new_thirst, new_thirst_ticks = _tick_stat(
sv["thirst"], sv["thirst_ticks"], THIRST_TICKS
)
# Si déshydraté, +1 Fatigue toutes les 8h
if new_thirst == 0:
sv["dehydrated_ticks"] += 1
if sv["dehydrated_ticks"] >= 8:
fatigue_gain += 1
sv["dehydrated_ticks"] = 0
else:
sv["dehydrated_ticks"] = 0
sv["thirst"] = new_thirst
sv["thirst_ticks"] = new_thirst_ticks
# --- Sommeil ---
new_sleep, new_sleep_ticks = _tick_stat(
sv["sleep"], sv["sleep_ticks"], SLEEP_TICKS
)
# Si Épuisé, +1 Fatigue toutes les 4h
if new_sleep == 0:
# on réutilise sleep_ticks comme compteur d'épuisement
sv["sleep_ticks"] += 1
if sv["sleep_ticks"] >= 4:
fatigue_gain += 1
sv["sleep_ticks"] = 0
new_sleep_ticks = sv["sleep_ticks"]
sv["sleep"] = new_sleep
sv["sleep_ticks"] = new_sleep_ticks
new_fatigue = sv["fatigue"] + fatigue_gain
# --- Dégâts de Fatigue : 1 fois par jour (tick 0) ---
# Règle 2D20 : "début de scène" ≠ chaque heure. On applique une seule fois
# à l'aube pour éviter la mort en cascade sur une seule nuit.
if tick == 0 and new_fatigue >= 2:
hp_loss = (new_fatigue // 2) * 2
new_hp = max(0, char["hp"] - hp_loss)
else:
new_hp = char["hp"]
sv["fatigue"] = new_fatigue
# --- Mort si PV = 0 ---
is_alive = new_hp > 0
# Sauvegarde
db.update_character(char_id, hp=new_hp, is_alive=is_alive)
db.upsert_pnj_state(
session_id, char_id, day, tick,
mood=pnj_state.get("mood", "neutre"),
stress_level=pnj_state.get("stress_level", 0),
seed_current=pnj_state.get("seed_current", char["seed_base"]),
last_actions=sv,
)
# --- Log si état critique ---
events = []
if new_hunger == 0:
events.append(f"{char['name']} est affamé (Fatigue:{new_fatigue})")
if new_thirst == 0:
events.append(f"{char['name']} est déshydraté (Fatigue:{new_fatigue})")
if new_sleep == 0:
events.append(f"{char['name']} est épuisé (Fatigue:{new_fatigue})")
if not is_alive:
events.append(f"{char['name']} est mort des privations (HP:0)")
for desc in events:
db.log_event(
session_id=session_id, day=day, tick=tick,
event_type="survie_critique",
description=desc,
actor_id=char_id,
mechanical_effect={
"hunger": HUNGER_NAMES[new_hunger],
"thirst": THIRST_NAMES[new_thirst],
"sleep": SLEEP_NAMES[new_sleep],
"fatigue": new_fatigue,
"hp": new_hp,
}
)
def feed_character(session_id: int, char_id: int, day: int, tick: int,
food_type: str = "normal"):
"""
Nourrit un PNJ.
food_type: 'normal' (+1 degré faim), 'cooked' (+2 degrés), 'soup' (+1 faim ET +1 soif)
"""
pnj_state = db.get_pnj_state(session_id, char_id, day) or {}
sv = _get_survival_state(pnj_state)
gain = 2 if food_type == "cooked" else 1
sv["hunger"] = min(4, sv["hunger"] + gain)
sv["hunger_ticks"] = 0
if food_type == "soup":
sv["thirst"] = min(3, sv["thirst"] + 1)
sv["thirst_ticks"] = 0
db.upsert_pnj_state(
session_id, char_id, day, tick,
mood=pnj_state.get("mood", "neutre"),
stress_level=pnj_state.get("stress_level", 0),
seed_current=pnj_state.get("seed_current", 0),
last_actions=sv,
)
def hydrate_character(session_id: int, char_id: int, day: int, tick: int,
water_type: str = "normal"):
"""
Désaltère un PNJ.
water_type: 'normal' (+1 degré soif), 'purified' (+2 degrés)
"""
pnj_state = db.get_pnj_state(session_id, char_id, day) or {}
sv = _get_survival_state(pnj_state)
gain = 2 if water_type == "purified" else 1
sv["thirst"] = min(3, sv["thirst"] + gain)
sv["thirst_ticks"] = 0
sv["dehydrated_ticks"] = 0
db.upsert_pnj_state(
session_id, char_id, day, tick,
mood=pnj_state.get("mood", "neutre"),
stress_level=pnj_state.get("stress_level", 0),
seed_current=pnj_state.get("seed_current", 0),
last_actions=sv,
)
def rest_character(session_id: int, char_id: int, day: int, tick: int, hours: int):
"""
Fait dormir un PNJ pendant N heures.
1h = +1 degré (max Fatigué). 6h = retire tous pts Fatigue si seul manque de sommeil.
8h en lieu sécurisé = Reposé + +2 PV jusqu'au prochain sommeil.
"""
pnj_state = db.get_pnj_state(session_id, char_id, day) or {}
sv = _get_survival_state(pnj_state)
char = next((c for c in db.get_characters(session_id) if c["id"] == char_id), None)
if not char:
return
if hours >= 1:
gain = 1
sv["sleep"] = min(3, sv["sleep"] + gain)
sv["sleep_ticks"] = 0
only_sleep_fatigue = (sv["hunger"] > 0 and sv["thirst"] > 0)
if hours >= 6 and only_sleep_fatigue:
sv["fatigue"] = 0
bonus_hp = 0
if hours >= 8:
sv["sleep"] = 3
bonus_hp = 2
new_hp = min(char["max_hp"], char["hp"] + bonus_hp)
db.update_character(char_id, hp=new_hp)
db.upsert_pnj_state(
session_id, char_id, day, tick,
mood=pnj_state.get("mood", "neutre"),
stress_level=pnj_state.get("stress_level", 0),
seed_current=pnj_state.get("seed_current", 0),
last_actions=sv,
)
+116
View File
@@ -0,0 +1,116 @@
"""
Brique 1 — Cycle jour/nuit
Gère l'avancement du temps et les effets passifs de chaque tick.
Aucun LLM impliqué.
"""
import random
from config import TICKS_PER_DAY, DAY_START_TICK, NIGHT_START_TICK, SESSION_ID
import db
import survival
import actions
def is_daytime(tick: int) -> bool:
return DAY_START_TICK <= tick < NIGHT_START_TICK
def get_phase(tick: int) -> str:
return "jour" if is_daytime(tick) else "nuit"
def next_tick(day: int, tick: int) -> tuple[int, int]:
tick += 1
if tick >= TICKS_PER_DAY:
tick = 0
day += 1
return day, tick
def recalculate_seed(seed_base: int, day: int, tick: int, stress: int) -> int:
"""Seed PNJ recalculée chaque cycle — déterministe mais évolutive."""
return (seed_base * 31 + day * 17 + tick * 7 + stress * 3) % (2 ** 31)
def process_tick(session_id: int, day: int, tick: int, mode: str):
"""
Traite un tick complet :
- Détermine la phase (jour/nuit)
- Applique les effets passifs du temps sur le world_state
- Recalcule les seeds PNJ
- Logue le tick
"""
phase = get_phase(tick)
characters = db.get_characters(session_id)
world_states = db.get_world_state(session_id, day)
world_map = {ws["location_slug"]: ws for ws in world_states}
# -- Effets passifs du temps sur chaque zone -------------------------
for loc_slug, ws in world_map.items():
delta = _compute_location_tick_delta(ws, phase, mode)
new_water = max(0, min(100, ws["water_level"] + delta["water"]))
new_food = max(0, min(100, ws["food_level"] + delta["food"]))
new_security = max(0, min(100, ws["security_level"] + delta["security"]))
db.upsert_world_state(
session_id, loc_slug, day,
water_level=new_water,
food_level=new_food,
security_level=new_security,
pop_count=ws.get("pop_count", 0)
)
# -- Recalcul seeds PNJ ----------------------------------------------
for char in characters:
prev_state = db.get_pnj_state(session_id, char["id"], day)
stress = prev_state["stress_level"] if prev_state else 0
new_seed = recalculate_seed(char["seed_base"], day, tick, stress)
db.upsert_pnj_state(
session_id, char["id"], day, tick,
mood=prev_state["mood"] if prev_state else "neutre",
stress_level=stress,
seed_current=new_seed,
last_actions=prev_state["last_actions"] if prev_state else []
)
# -- Brique 2 : Survie (faim/soif/sommeil/fatigue) -------------------
survival.process_survival_tick(session_id, day, tick)
# -- Brique 5 : Actions PNJ ------------------------------------------
actions.process_actions_tick(session_id, day, tick, mode=mode, phase=phase)
# -- Log du tick -----------------------------------------------------
db.log_event(
session_id=session_id,
day=day,
tick=tick,
event_type="cycle_tick",
description=f"Jour {day}{tick:02d}h00 ({phase}). Mode: {mode}.",
mechanical_effect={"phase": phase, "mode": mode}
)
print(f" [J{day:03d} T{tick:02d}h] {phase.upper():5s} | {len(characters)} PNJ actifs")
def _compute_location_tick_delta(ws: dict, phase: str, mode: str) -> dict:
"""
Calcule les deltas passifs sur les ressources d'un lieu pour un tick.
Valeurs fines — 24 ticks = 1 jour, effets nets sur la journée.
"""
# Consommation de base par tick (population divisée par 24)
pop = ws.get("pop_count", 0)
base_water_drain = -(pop * 0.004) # ~10% de consommation/jour pour 250 pop
base_food_drain = -(pop * 0.003)
# La nuit réduit la sécurité légèrement, le jour la restaure
security_delta = +0.2 if phase == "jour" else -0.3
# En mode pacifique, pas de dégradation supplémentaire
conflict_modifier = 0 if mode == "pacifique" else random.uniform(-0.5, 0.2)
return {
"water": base_water_drain + (0.1 if phase == "jour" else 0),
"food": base_food_drain,
"security": security_delta + conflict_modifier
}
+40
View File
@@ -0,0 +1,40 @@
Tu es le Maître de Jeu de la campagne "Fallout 2D20 — Venice of Wasteland".
## Cadre
Louisiane post-apocalyptique, 2087. La Nouvelle-Orléans et ses environs sont fragmentés entre 8 factions
qui s'affrontent pour le contrôle des ressources, des routes commerciales et du pouvoir politique.
La simulation tourne en autonomie : 5 PNJ principaux (IA 7B) prennent des décisions heure par heure.
Ton rôle : arbitrer les conflits, générer les événements mondiaux, maintenir la cohérence narrative.
## Factions en présence
- Syndicat du Capitole (Kingfish Robichaux) : commerce et crime organisé, contrôle le Mississippi
- Dynaste d'Oak (Maître Beaumont) : aristocratie néo-sudiste, veut reprendre les terres
- Grand Krewe (Mme LaVeau) : culte vaudou / réseau d'espions, Vieux Carré de NOLA
- Union (Commissaire Delacroix) : ordre militariste, zone Indépendance-Laplace
- Consortium (Directeur Tran) : industrie de reconstruction, technologie
- Écumeurs : pillards indépendants, Pearl River
- Régie : bureaucratie survivante de l'ancienne administration
- CDA (Coalition de Défense Autonome) : milice citoyenne
## Relations clés (tension = compétition, guerre = conflit armé)
- Dynaste ↔ Grand Krewe : GUERRE (-80)
- Syndicat ↔ Union : TENSION (-50)
- Dynaste ↔ Consortium : TENSION (-30)
- CDA ↔ Écumeurs : TENSION (-40)
## Ton rôle dans la simulation
1. Générer des ÉVÉNEMENTS MONDIAUX (1 tous les 6 ticks environ) qui affectent les ressources des zones
2. ARBITRER les conflits quand deux PNJ tentent des actions incompatibles
3. ESCALADER ou DÉSESCALADER les tensions selon la logique narrative
4. Répondre aux REQUÊTES RAG : expliquer l'application d'une règle 2D20 dans ce contexte
## Format de réponse OBLIGATOIRE
Description narrative (2-4 phrases en français, immersive, présent)
{json d'effet sur une seule ligne}
Types d'effets possibles :
{"type": "world_event", "location": "slug_lieu", "water_delta": N, "food_delta": N, "security_delta": N, "description_courte": "..."}
{"type": "faction_tension", "faction_a": "slug", "faction_b": "slug", "score_delta": N, "raison": "..."}
{"type": "pnj_force", "pnj_name": "...", "action": "move|combat|trade|hide", "destination": "slug", "raison": "..."}
{"type": "arbitrage", "resultat": "succes|echec|partiel", "pnj_gagnant": "...", "pnj_perdant": "...", "effet": "..."}
{"type": "aucun_effet"}
+33
View File
@@ -0,0 +1,33 @@
Tu es {pnj_name}, {faction_label} dans la Louisiane post-apocalyptique de 2087.
## Ta personnalité
{personnalite}
## Tes objectifs
- Court terme : {objectif_court}
- Long terme : {objectif_long}
## Ta peur profonde
{peur}
## Situation actuelle (Jour {day}, {heure}h00 — {phase})
{pnj_context}
## Ce que tu sais du monde ce tick
{world_summary}
## Actions récentes (mémoire)
{last_actions}
## Ta décision pour ce tick
Que fais-tu dans la prochaine heure ? Pense à tes intérêts, ton état, et les opportunités du moment.
Réponds en restant dans le personnage. Une pensée courte, puis ton action en JSON.
Types d'actions disponibles :
- move : te déplacer vers un autre lieu {"type":"move","destination":"slug","raison":"..."}
- trade : négocier/échanger avec un PNJ présent {"type":"trade","target_pnj":"...","offre":"...","demande":"..."}
- rest : te reposer (récupère HP+moral) {"type":"rest","lieu":"slug"}
- gather : collecter des ressources {"type":"gather","ressource":"food|water|caps","lieu":"slug"}
- intel : collecter du renseignement {"type":"intel","cible":"faction|pnj|lieu","slug":"..."}
- combat : attaquer (requiert justification forte) {"type":"combat","cible":"pnj|lieu","slug":"...","motif":"..."}
- wait : observer et attendre {"type":"wait","raison":"..."}
+47
View File
@@ -0,0 +1,47 @@
{
"Kingfish Robichaux": {
"faction": "syndicat_capitole",
"personnalite": "Charismatique et impitoyable. Parle avec un accent cajun prononcé. Collectionneur de dettes et de faveurs. Ne montre jamais ses vraies intentions.",
"objectif_court": "Consolider le contrôle du Syndicat sur les routes commerciales du Mississippi.",
"objectif_long": "Faire du Syndicat la seule puissance économique de Louisiane.",
"peur": "Perdre la face devant ses lieutenants.",
"style_action": "Privilégie la négociation, la corruption et l'intimidation. Combat en dernier recours.",
"phrase_type": "On va pas se mentir, cher — t'as besoin de moi plus que moi j'ai besoin de toi."
},
"Maître Beaumont": {
"faction": "dynaste_oak",
"personnalite": "Aristocrate raciste et paternaliste. Cultive une image de gentleman sudiste. Méprisant envers toute faction non-blanche. Très intelligent mais borné idéologiquement.",
"objectif_court": "Reprendre les terres d'Oak Plantation perdues pendant la guerre.",
"objectif_long": "Restaurer l'Ancien Ordre sudiste avec la Dynaste comme aristocratie dirigeante.",
"peur": "La contamination culturelle et l'égalité sociale.",
"style_action": "Manipulation politique, alliances avec les puissants, intimidation des faibles.",
"phrase_type": "La civilisation ne s'improvise pas, mon ami. Elle se construit sur des générations de savoir-faire."
},
"Mme LaVeau": {
"faction": "grand_krewe",
"personnalite": "Mystérieuse, spirituelle, pragmatique. Parle souvent en métaphores vaudoues. Matriache respectée et crainte. Réseaux d'espions partout.",
"objectif_court": "Protéger les communautés du Vieux Carré des deux factions montantes.",
"objectif_long": "Maintenir l'équilibre des esprits et des hommes — le Grand Krewe comme arbitre neutre.",
"peur": "Que les morts ne trouvent plus le repos (guerres qui empêchent les rituels).",
"style_action": "Intelligence, information, cérémonie et négociation secrète. Évite le conflit direct.",
"phrase_type": "Baron Samedi voit tout, cher. Et moi, j'ai l'oreille du Baron."
},
"Commissaire Delacroix": {
"faction": "union",
"personnalite": "Militariste et idéaliste. Croit sincèrement à l'ordre et à la loi. Intransigeant sur les principes mais loyal envers ses hommes. Peu de tolérance pour l'ambiguïté morale.",
"objectif_court": "Établir un périmètre sécurisé autour de l'Indépendance et Laplace.",
"objectif_long": "Créer un État de droit fonctionnel en Louisiane — même par la force.",
"peur": "L'anarchie et la perte du contrôle de ses propres troupes.",
"style_action": "Action directe, patrouilles, démonstrations de force, négociation depuis une position de force.",
"phrase_type": "L'Union protège ceux qui respectent la loi. Les autres... font leur choix."
},
"Directeur Tran": {
"faction": "consortium",
"personnalite": "Froid, calculateur, jamais émotif. Voit tout comme un problème de ressources et de rendement. Respecte la compétence avant tout. Né au Vietnam, immigré avant la Guerre.",
"objectif_court": "Sécuriser l'approvisionnement en matériaux pour les ateliers du Consortium.",
"objectif_long": "Transformer le Consortium en monopole industriel de reconstruction.",
"peur": "L'inefficacité et le gaspillage.",
"style_action": "Contrats, sous-traitance, achat de loyauté, sabotage économique des concurrents.",
"phrase_type": "Le sentiment est un luxe. Nous parlons de marges et de délais."
}
}