commit 3039961b51ab173a50805606b7d23ffbbcde31c1 Author: Corback Date: Mon Jun 15 22:38:35 2026 +0000 Initial commit — Venice of Wasteland sim engine B1-B5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..435f7f2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.pyc +*.pyo +.env +*.log +*.sqlite +.DS_Store +Thumbs.db diff --git a/src/db/fixtures.sql b/src/db/fixtures.sql new file mode 100644 index 0000000..237ff5e --- /dev/null +++ b/src/db/fixtures.sql @@ -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); diff --git a/src/db/init_db.sh b/src/db/init_db.sh new file mode 100755 index 0000000..38d17c2 --- /dev/null +++ b/src/db/init_db.sh @@ -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" diff --git a/src/db/migration_001_fix_hp_and_inventory.sql b/src/db/migration_001_fix_hp_and_inventory.sql new file mode 100644 index 0000000..3216695 --- /dev/null +++ b/src/db/migration_001_fix_hp_and_inventory.sql @@ -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; diff --git a/src/db/migration_002_role_origin_inventory.sql b/src/db/migration_002_role_origin_inventory.sql new file mode 100644 index 0000000..ab51b8a --- /dev/null +++ b/src/db/migration_002_role_origin_inventory.sql @@ -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'; diff --git a/src/db/schema.sql b/src/db/schema.sql new file mode 100644 index 0000000..eb7ddd1 --- /dev/null +++ b/src/db/schema.sql @@ -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); diff --git a/src/engine/actions.py b/src/engine/actions.py new file mode 100644 index 0000000..1db7cd0 --- /dev/null +++ b/src/engine/actions.py @@ -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 diff --git a/src/engine/character_creator.py b/src/engine/character_creator.py new file mode 100644 index 0000000..b569e26 --- /dev/null +++ b/src/engine/character_creator.py @@ -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) diff --git a/src/engine/config.py b/src/engine/config.py new file mode 100644 index 0000000..9eeaec0 --- /dev/null +++ b/src/engine/config.py @@ -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)) diff --git a/src/engine/dashboard.py b/src/engine/dashboard.py new file mode 100644 index 0000000..9d39c7f --- /dev/null +++ b/src/engine/dashboard.py @@ -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 = """ + + + +Fallout : Venice of Wasteland — Dashboard + + + +

☢ FALLOUT : VENICE OF WASTELAND

+
Chargement...
+ +
+

Session

+

PNJ Simulés

+

État du Monde

+

Journal des Events

+
+ +
Rafraîchissement automatique toutes les 3s
+ + + + +""" + + +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() diff --git a/src/engine/db.py b/src/engine/db.py new file mode 100644 index 0000000..c68e47a --- /dev/null +++ b/src/engine/db.py @@ -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 + ) diff --git a/src/engine/init_pnj.py b/src/engine/init_pnj.py new file mode 100644 index 0000000..8800eb3 --- /dev/null +++ b/src/engine/init_pnj.py @@ -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() diff --git a/src/engine/inventory.py b/src/engine/inventory.py new file mode 100644 index 0000000..c8a5c9f --- /dev/null +++ b/src/engine/inventory.py @@ -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) diff --git a/src/engine/llm.py b/src/engine/llm.py new file mode 100644 index 0000000..c8385a8 --- /dev/null +++ b/src/engine/llm.py @@ -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)}") diff --git a/src/engine/requirements.txt b/src/engine/requirements.txt new file mode 100644 index 0000000..83ec125 --- /dev/null +++ b/src/engine/requirements.txt @@ -0,0 +1 @@ +psycopg2-binary>=2.9 diff --git a/src/engine/run.py b/src/engine/run.py new file mode 100644 index 0000000..d56b174 --- /dev/null +++ b/src/engine/run.py @@ -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() diff --git a/src/engine/survival.py b/src/engine/survival.py new file mode 100644 index 0000000..50d745c --- /dev/null +++ b/src/engine/survival.py @@ -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, + ) diff --git a/src/engine/tick.py b/src/engine/tick.py new file mode 100644 index 0000000..e7e5390 --- /dev/null +++ b/src/engine/tick.py @@ -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 + } diff --git a/src/prompts/mj_system.txt b/src/prompts/mj_system.txt new file mode 100644 index 0000000..414fdfe --- /dev/null +++ b/src/prompts/mj_system.txt @@ -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"} diff --git a/src/prompts/pnj_action_prompt.txt b/src/prompts/pnj_action_prompt.txt new file mode 100644 index 0000000..04bf1f7 --- /dev/null +++ b/src/prompts/pnj_action_prompt.txt @@ -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":"..."} diff --git a/src/prompts/pnj_profiles.json b/src/prompts/pnj_profiles.json new file mode 100644 index 0000000..4664fa2 --- /dev/null +++ b/src/prompts/pnj_profiles.json @@ -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." + } +}