Initial commit — Venice of Wasteland sim engine B1-B5
This commit is contained in:
@@ -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);
|
||||
Executable
+37
@@ -0,0 +1,37 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# init_db.sh — Initialisation DB Fallout sur Vigile
|
||||
# Usage : bash init_db.sh
|
||||
# Pré-requis : psql installé, accès SSH à Vigile ou exécuté directement dessus
|
||||
# =============================================================================
|
||||
|
||||
set -e
|
||||
|
||||
DB_HOST="${DB_HOST:-localhost}"
|
||||
DB_PORT="${DB_PORT:-5432}"
|
||||
DB_NAME="${DB_NAME:-fallout}"
|
||||
DB_USER="${DB_USER:-fallout}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
echo "=== Fallout DB Init ==="
|
||||
echo "Host : $DB_HOST:$DB_PORT | DB : $DB_NAME | User : $DB_USER"
|
||||
echo ""
|
||||
|
||||
# Appliquer le schéma
|
||||
echo "[1/2] Application du schéma..."
|
||||
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" \
|
||||
-f "$SCRIPT_DIR/schema.sql" \
|
||||
&& echo " Schéma OK" \
|
||||
|| { echo "ERREUR schéma"; exit 1; }
|
||||
|
||||
# Appliquer les fixtures
|
||||
echo "[2/2] Insertion des fixtures..."
|
||||
psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" \
|
||||
-f "$SCRIPT_DIR/fixtures.sql" \
|
||||
&& echo " Fixtures OK" \
|
||||
|| { echo "ERREUR fixtures"; exit 1; }
|
||||
|
||||
echo ""
|
||||
echo "=== DB initialisée avec succès ==="
|
||||
echo "Session 1 : 'Simulation Alpha — 5 PNJ × 30 jours' (mode pacifique)"
|
||||
echo "Factions : 8 | Lieux : 10 | PNJ simulés : 5"
|
||||
@@ -0,0 +1,82 @@
|
||||
-- =============================================================================
|
||||
-- Migration 001 — Correction HP (END+LCK) + Table inventory
|
||||
-- Règle officielle Fallout 2D20 Rules Booklet p.13 : max_hp = END + LCK
|
||||
-- =============================================================================
|
||||
|
||||
-- Recalcul max_hp et hp pour tous les personnages existants
|
||||
UPDATE characters
|
||||
SET
|
||||
max_hp = endurance + luck,
|
||||
hp = endurance + luck
|
||||
WHERE session_id = 1;
|
||||
|
||||
-- =============================================================================
|
||||
-- Table inventory
|
||||
-- Poids max = 150 + (STR * 10) — Rules Booklet p.12
|
||||
-- =============================================================================
|
||||
CREATE TABLE IF NOT EXISTS inventory (
|
||||
id SERIAL PRIMARY KEY,
|
||||
character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
|
||||
session_id INTEGER NOT NULL,
|
||||
item_slug TEXT NOT NULL,
|
||||
item_name TEXT NOT NULL,
|
||||
item_type TEXT NOT NULL, -- weapon / armor / food / drink / drug / misc / ammo
|
||||
quantity INTEGER DEFAULT 1,
|
||||
weight NUMERIC(6,2) DEFAULT 0,
|
||||
-- Stats optionnelles selon type
|
||||
hp_heal INTEGER DEFAULT 0,
|
||||
rad_heal INTEGER DEFAULT 0,
|
||||
damage_dice INTEGER DEFAULT 0, -- CD (Combat Dice)
|
||||
damage_bonus INTEGER DEFAULT 0,
|
||||
damage_type TEXT, -- physical / energy / poison / radiation
|
||||
damage_range TEXT, -- short / medium / long / extreme
|
||||
armor_rating INTEGER DEFAULT 0,
|
||||
properties JSONB DEFAULT '{}', -- effets spéciaux libres
|
||||
is_equipped BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_inventory_character ON inventory(character_id, session_id);
|
||||
|
||||
-- =============================================================================
|
||||
-- Équipements de départ pour les 5 PNJ
|
||||
-- =============================================================================
|
||||
INSERT INTO inventory (character_id, session_id, item_slug, item_name, item_type, quantity, weight, damage_dice, damage_type, damage_range, properties)
|
||||
SELECT id, 1,
|
||||
'pistolet_10mm', 'Pistolet 10mm', 'weapon', 1, 1.5, 2, 'physical', 'medium',
|
||||
'{"special": "Bruyant"}'::jsonb
|
||||
FROM characters WHERE session_id=1 AND name='Kingfish Robichaux';
|
||||
|
||||
INSERT INTO inventory (character_id, session_id, item_slug, item_name, item_type, quantity, weight, damage_dice, damage_type, damage_range, properties)
|
||||
SELECT id, 1,
|
||||
'fusil_chasse', 'Fusil de chasse', 'weapon', 1, 3.0, 3, 'physical', 'medium',
|
||||
'{"special": "Dispersion"}'::jsonb
|
||||
FROM characters WHERE session_id=1 AND name='Maître Beaumont';
|
||||
|
||||
INSERT INTO inventory (character_id, session_id, item_slug, item_name, item_type, quantity, weight, damage_dice, damage_type, damage_range, properties)
|
||||
SELECT id, 1,
|
||||
'baton_vaudou', 'Bâton de Mambo', 'weapon', 1, 1.0, 2, 'physical', 'short',
|
||||
'{"special": "Crit+Poison"}'::jsonb
|
||||
FROM characters WHERE session_id=1 AND name='Mme LaVeau';
|
||||
|
||||
INSERT INTO inventory (character_id, session_id, item_slug, item_name, item_type, quantity, weight, damage_dice, damage_type, damage_range, properties)
|
||||
SELECT id, 1,
|
||||
'pistolet_laser', 'Pistolet laser', 'weapon', 1, 1.0, 2, 'energy', 'medium',
|
||||
'{"special": "Crit+Brûlure"}'::jsonb
|
||||
FROM characters WHERE session_id=1 AND name='Commissaire Delacroix';
|
||||
|
||||
INSERT INTO inventory (character_id, session_id, item_slug, item_name, item_type, quantity, weight, damage_dice, damage_type, damage_range, properties)
|
||||
SELECT id, 1,
|
||||
'pistolet_10mm', 'Pistolet 10mm', 'weapon', 1, 1.5, 2, 'physical', 'medium',
|
||||
'{"special": "Bruyant"}'::jsonb
|
||||
FROM characters WHERE session_id=1 AND name='Directeur Tran';
|
||||
|
||||
-- Nourriture de départ (3 rations chacun)
|
||||
INSERT INTO inventory (character_id, session_id, item_slug, item_name, item_type, quantity, weight, hp_heal, properties)
|
||||
SELECT id, 1, 'cram', 'Cram', 'food', 3, 0.5, 5, '{}'::jsonb
|
||||
FROM characters WHERE session_id=1;
|
||||
|
||||
-- Eau sale (à purifier)
|
||||
INSERT INTO inventory (character_id, session_id, item_slug, item_name, item_type, quantity, weight, properties)
|
||||
SELECT id, 1, 'eau_sale', 'Eau sale', 'drink', 2, 0.5, '{"irradiated": true, "rad_on_drink": 1}'::jsonb
|
||||
FROM characters WHERE session_id=1;
|
||||
@@ -0,0 +1,169 @@
|
||||
-- =============================================================================
|
||||
-- Migration 002 — Ajout role/origin/traits sur characters
|
||||
-- Remplacement table inventory (schéma cohérent avec le code)
|
||||
-- =============================================================================
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 1. Colonnes manquantes sur characters
|
||||
-- -----------------------------------------------------------------------------
|
||||
ALTER TABLE characters
|
||||
ADD COLUMN IF NOT EXISTS role TEXT DEFAULT 'survivant_generique',
|
||||
ADD COLUMN IF NOT EXISTS origin TEXT DEFAULT 'survivant',
|
||||
ADD COLUMN IF NOT EXISTS needs_food BOOLEAN DEFAULT TRUE,
|
||||
ADD COLUMN IF NOT EXISTS needs_water BOOLEAN DEFAULT TRUE,
|
||||
ADD COLUMN IF NOT EXISTS needs_sleep BOOLEAN DEFAULT TRUE,
|
||||
ADD COLUMN IF NOT EXISTS immune_radiation BOOLEAN DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS immune_poison BOOLEAN DEFAULT FALSE;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 2. Remplacement de la table inventory
|
||||
-- Le schéma original (schema.sql) utilisait owner_type/owner_id/item_category.
|
||||
-- Le code (db.py, migration_001) utilise character_id/session_id/item_type/weight/...
|
||||
-- On supprime l'ancienne structure et on recrée proprement.
|
||||
-- -----------------------------------------------------------------------------
|
||||
DROP TABLE IF EXISTS inventory CASCADE;
|
||||
|
||||
CREATE TABLE inventory (
|
||||
id SERIAL PRIMARY KEY,
|
||||
character_id INTEGER NOT NULL REFERENCES characters(id) ON DELETE CASCADE,
|
||||
session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
item_slug TEXT NOT NULL,
|
||||
item_name TEXT NOT NULL,
|
||||
-- weapon_melee | weapon_ranged | weapon_energy | armor | food | drink | drug | ammo | misc
|
||||
item_type TEXT NOT NULL,
|
||||
quantity INTEGER DEFAULT 1,
|
||||
weight NUMERIC(6,2) DEFAULT 0, -- kg (Manuel FR)
|
||||
-- Stats optionnelles
|
||||
hp_heal INTEGER DEFAULT 0,
|
||||
rad_heal INTEGER DEFAULT 0,
|
||||
damage_dice INTEGER DEFAULT 0, -- nombre de CD (dés de combat)
|
||||
damage_bonus INTEGER DEFAULT 0,
|
||||
damage_type TEXT, -- physical | energy | poison | radiation
|
||||
damage_range TEXT, -- short | medium | long | extreme
|
||||
armor_rating INTEGER DEFAULT 0,
|
||||
properties JSONB DEFAULT '{}', -- effets libres (Brutal, Perforant X, etc.)
|
||||
is_equipped BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE (character_id, session_id, item_slug)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_inventory_character ON inventory(character_id, session_id);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 3. Équipement de départ pour les 10 PNJ (session 1)
|
||||
-- Arme selon rôle + 3 rations + 2 eaux sales
|
||||
-- -----------------------------------------------------------------------------
|
||||
|
||||
-- Gardes → pistolet 10mm équipé
|
||||
INSERT INTO inventory
|
||||
(character_id, session_id, item_slug, item_name, item_type,
|
||||
quantity, weight, damage_dice, damage_type, damage_range, is_equipped)
|
||||
SELECT id, 1, 'pistolet_10mm', 'Pistolet 10mm', 'weapon_ranged',
|
||||
1, 1.5, 2, 'physical', 'medium', TRUE
|
||||
FROM characters
|
||||
WHERE session_id = 1 AND role = 'garde'
|
||||
ON CONFLICT (character_id, session_id, item_slug) DO NOTHING;
|
||||
|
||||
-- Éclaireurs → fusil de chasse équipé
|
||||
INSERT INTO inventory
|
||||
(character_id, session_id, item_slug, item_name, item_type,
|
||||
quantity, weight, damage_dice, damage_type, damage_range, is_equipped)
|
||||
SELECT id, 1, 'fusil_chasse', 'Fusil de chasse', 'weapon_ranged',
|
||||
1, 3.0, 3, 'physical', 'medium', TRUE
|
||||
FROM characters
|
||||
WHERE session_id = 1 AND role = 'eclaireur'
|
||||
ON CONFLICT (character_id, session_id, item_slug) DO NOTHING;
|
||||
|
||||
-- Pillards → couteau de combat équipé
|
||||
INSERT INTO inventory
|
||||
(character_id, session_id, item_slug, item_name, item_type,
|
||||
quantity, weight, damage_dice, damage_type, damage_range, is_equipped)
|
||||
SELECT id, 1, 'couteau_combat', 'Couteau de combat', 'weapon_melee',
|
||||
1, 0.5, 2, 'physical', 'short', TRUE
|
||||
FROM characters
|
||||
WHERE session_id = 1 AND role = 'pillard'
|
||||
ON CONFLICT (character_id, session_id, item_slug) DO NOTHING;
|
||||
|
||||
-- Marchands → pistolet 10mm équipé
|
||||
INSERT INTO inventory
|
||||
(character_id, session_id, item_slug, item_name, item_type,
|
||||
quantity, weight, damage_dice, damage_type, damage_range, is_equipped)
|
||||
SELECT id, 1, 'pistolet_10mm', 'Pistolet 10mm', 'weapon_ranged',
|
||||
1, 1.5, 2, 'physical', 'medium', TRUE
|
||||
FROM characters
|
||||
WHERE session_id = 1 AND role = 'marchand'
|
||||
ON CONFLICT (character_id, session_id, item_slug) DO NOTHING;
|
||||
|
||||
-- Docteur → stimpaks (outil de travail)
|
||||
INSERT INTO inventory
|
||||
(character_id, session_id, item_slug, item_name, item_type,
|
||||
quantity, weight, hp_heal)
|
||||
SELECT id, 1, 'stimpak', 'Stimpak', 'drug', 3, 0.2, 4
|
||||
FROM characters
|
||||
WHERE session_id = 1 AND role = 'docteur'
|
||||
ON CONFLICT (character_id, session_id, item_slug) DO NOTHING;
|
||||
|
||||
-- Technicien → pistolet laser équipé
|
||||
INSERT INTO inventory
|
||||
(character_id, session_id, item_slug, item_name, item_type,
|
||||
quantity, weight, damage_dice, damage_type, damage_range, is_equipped)
|
||||
SELECT id, 1, 'pistolet_laser', 'Pistolet laser', 'weapon_energy',
|
||||
1, 1.0, 2, 'energy', 'medium', TRUE
|
||||
FROM characters
|
||||
WHERE session_id = 1 AND role = 'technicien'
|
||||
ON CONFLICT (character_id, session_id, item_slug) DO NOTHING;
|
||||
|
||||
-- Survivant générique → bâton (mains nues si absent)
|
||||
INSERT INTO inventory
|
||||
(character_id, session_id, item_slug, item_name, item_type,
|
||||
quantity, weight, damage_dice, damage_type, damage_range, is_equipped)
|
||||
SELECT id, 1, 'baton', 'Bâton', 'weapon_melee',
|
||||
1, 1.0, 1, 'physical', 'short', TRUE
|
||||
FROM characters
|
||||
WHERE session_id = 1 AND role = 'survivant_generique'
|
||||
ON CONFLICT (character_id, session_id, item_slug) DO NOTHING;
|
||||
|
||||
-- Nourriture de départ : 3 rations Cram pour tous
|
||||
INSERT INTO inventory
|
||||
(character_id, session_id, item_slug, item_name, item_type,
|
||||
quantity, weight, hp_heal)
|
||||
SELECT id, 1, 'cram', 'Cram', 'food', 3, 0.5, 2
|
||||
FROM characters WHERE session_id = 1
|
||||
ON CONFLICT (character_id, session_id, item_slug) DO NOTHING;
|
||||
|
||||
-- Eau sale (2 unités, irradiée) pour tous
|
||||
INSERT INTO inventory
|
||||
(character_id, session_id, item_slug, item_name, item_type,
|
||||
quantity, weight, properties)
|
||||
SELECT id, 1, 'eau_sale', 'Eau sale', 'drink', 2, 0.5,
|
||||
'{"irradiated": true, "rad_on_drink": 1}'::jsonb
|
||||
FROM characters WHERE session_id = 1
|
||||
ON CONFLICT (character_id, session_id, item_slug) DO NOTHING;
|
||||
|
||||
-- Marchands → stock supplémentaire (pour le commerce)
|
||||
INSERT INTO inventory
|
||||
(character_id, session_id, item_slug, item_name, item_type,
|
||||
quantity, weight, hp_heal)
|
||||
SELECT id, 1, 'igname_cuite', 'Igname cuite', 'food', 5, 0.3, 3
|
||||
FROM characters WHERE session_id = 1 AND role = 'marchand'
|
||||
ON CONFLICT (character_id, session_id, item_slug) DO NOTHING;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 4. Recalcul HP selon règle officielle (END + LCK) — Manuel FR p.51
|
||||
-- -----------------------------------------------------------------------------
|
||||
UPDATE characters
|
||||
SET max_hp = endurance + luck,
|
||||
hp = endurance + luck
|
||||
WHERE session_id = 1;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- 5. Mise à jour traits origine pour les PNJ existants
|
||||
-- -----------------------------------------------------------------------------
|
||||
UPDATE characters SET immune_radiation = TRUE, needs_food = TRUE
|
||||
WHERE session_id = 1 AND origin = 'goule';
|
||||
|
||||
UPDATE characters SET immune_radiation = TRUE, immune_poison = TRUE
|
||||
WHERE session_id = 1 AND origin IN ('super_mutant', 'mister_handy');
|
||||
|
||||
UPDATE characters SET needs_food = FALSE, needs_water = FALSE, needs_sleep = FALSE
|
||||
WHERE session_id = 1 AND origin = 'mister_handy';
|
||||
@@ -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);
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -0,0 +1,267 @@
|
||||
"""
|
||||
Dashboard de monitoring — Fallout Venice of Wasteland
|
||||
Lance un serveur HTTP local sur le port 8765
|
||||
Usage : python dashboard.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys, os
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from datetime import datetime
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
import db
|
||||
from config import SESSION_ID, TICKS_PER_DAY, DAY_START_TICK, NIGHT_START_TICK
|
||||
|
||||
|
||||
def get_state() -> dict:
|
||||
try:
|
||||
session = db.get_session(SESSION_ID)
|
||||
day = session["current_day"]
|
||||
tick = session["current_tick"]
|
||||
characters = db.get_characters(SESSION_ID)
|
||||
world = db.get_world_state(SESSION_ID, day)
|
||||
|
||||
# Derniers events
|
||||
with db.cursor() as cur:
|
||||
cur.execute(
|
||||
"""SELECT day, tick, event_type, description, created_at
|
||||
FROM events WHERE session_id=%s
|
||||
ORDER BY id DESC LIMIT 30""",
|
||||
(SESSION_ID,)
|
||||
)
|
||||
events = [dict(r) for r in cur.fetchall()]
|
||||
|
||||
# States PNJ
|
||||
pnj_states = []
|
||||
for char in characters:
|
||||
state = db.get_pnj_state(SESSION_ID, char["id"], day)
|
||||
pnj_states.append({**char, "state": state})
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"session": {
|
||||
"id": session["id"],
|
||||
"name": session["name"],
|
||||
"day": day,
|
||||
"tick": tick,
|
||||
"mode": session["mode"],
|
||||
"status": session["status"],
|
||||
"seed": session["seed_global"],
|
||||
"phase": "JOUR" if DAY_START_TICK <= tick < NIGHT_START_TICK else "NUIT",
|
||||
},
|
||||
"world": world,
|
||||
"characters": pnj_states,
|
||||
"events": events,
|
||||
"fetched_at": datetime.now().isoformat(),
|
||||
}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
HTML = """<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Fallout : Venice of Wasteland — Dashboard</title>
|
||||
<style>
|
||||
:root { --green:#4caf50;--yellow:#ffeb3b;--red:#f44336;--bg:#1a1a1a;--card:#252525;--text:#e0e0e0;--dim:#888; }
|
||||
* { box-sizing:border-box; margin:0; padding:0; }
|
||||
body { background:var(--bg); color:var(--text); font-family:'Courier New',monospace; font-size:13px; padding:16px; }
|
||||
h1 { color:var(--green); font-size:18px; letter-spacing:2px; margin-bottom:4px; }
|
||||
.meta { color:var(--dim); font-size:11px; margin-bottom:16px; }
|
||||
.grid { display:grid; grid-template-columns:1fr 1fr; gap:12px; margin-bottom:12px; }
|
||||
.card { background:var(--card); border:1px solid #333; border-radius:4px; padding:12px; }
|
||||
.card h2 { font-size:12px; letter-spacing:1px; color:var(--green); margin-bottom:10px; text-transform:uppercase; }
|
||||
/* Session */
|
||||
.phase-jour { color:var(--yellow); font-size:24px; font-weight:bold; }
|
||||
.phase-nuit { color:#7986cb; font-size:24px; font-weight:bold; }
|
||||
.session-row { display:flex; justify-content:space-between; margin:4px 0; }
|
||||
.label { color:var(--dim); }
|
||||
/* Barres ressources */
|
||||
.loc { margin-bottom:10px; }
|
||||
.loc-name { color:#fff; font-weight:bold; margin-bottom:4px; font-size:12px; }
|
||||
.bar-row { display:flex; align-items:center; gap:8px; margin:2px 0; }
|
||||
.bar-label { width:70px; color:var(--dim); font-size:11px; }
|
||||
.bar-bg { flex:1; background:#333; border-radius:2px; height:10px; }
|
||||
.bar-fill { height:10px; border-radius:2px; transition:width .5s; }
|
||||
.bar-val { width:35px; text-align:right; font-size:11px; color:var(--dim); }
|
||||
/* PNJ */
|
||||
.pnj { margin-bottom:8px; border-bottom:1px solid #333; padding-bottom:8px; }
|
||||
.pnj-name { font-weight:bold; color:#fff; }
|
||||
.pnj-mood { display:inline-block; padding:1px 6px; border-radius:3px; font-size:10px; margin-left:6px; }
|
||||
.mood-agressif { background:#c62828; }
|
||||
.mood-defensif { background:#1565c0; }
|
||||
.mood-neutre { background:#424242; }
|
||||
.mood-opportuniste { background:#6a1b9a; }
|
||||
.mood-craintif { background:#4e342e; }
|
||||
.mood-diplomatique { background:#1b5e20; }
|
||||
.pnj-row { color:var(--dim); font-size:11px; margin-top:2px; }
|
||||
.hp-bar { display:inline-block; width:80px; height:6px; background:#333; border-radius:2px; vertical-align:middle; margin:0 4px; }
|
||||
.hp-fill { height:6px; border-radius:2px; background:var(--green); }
|
||||
/* Events */
|
||||
.events { grid-column:1/-1; }
|
||||
.event-list { max-height:220px; overflow-y:auto; }
|
||||
.event { padding:4px 0; border-bottom:1px solid #2a2a2a; font-size:11px; }
|
||||
.event-time { color:var(--green); margin-right:8px; }
|
||||
.event-type { color:var(--yellow); margin-right:8px; }
|
||||
/* Status */
|
||||
.status-ok { color:var(--green); }
|
||||
.status-err { color:var(--red); }
|
||||
.refresh { color:var(--dim); font-size:10px; margin-top:12px; text-align:center; }
|
||||
.mode-pacifique { color:#4fc3f7; }
|
||||
.mode-normal { color:var(--yellow); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>☢ FALLOUT : VENICE OF WASTELAND</h1>
|
||||
<div class="meta" id="meta">Chargement...</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="card" id="card-session"><h2>Session</h2><div id="session-content">—</div></div>
|
||||
<div class="card" id="card-pnj"><h2>PNJ Simulés</h2><div id="pnj-content">—</div></div>
|
||||
<div class="card" id="card-world"><h2>État du Monde</h2><div id="world-content">—</div></div>
|
||||
<div class="card events" id="card-events"><h2>Journal des Events</h2><div class="event-list" id="events-content">—</div></div>
|
||||
</div>
|
||||
|
||||
<div class="refresh">Rafraîchissement automatique toutes les 3s</div>
|
||||
|
||||
<script>
|
||||
function barColor(v) {
|
||||
if (v >= 60) return '#4caf50';
|
||||
if (v >= 30) return '#ffeb3b';
|
||||
return '#f44336';
|
||||
}
|
||||
|
||||
function renderBar(label, value) {
|
||||
const v = Math.round(value);
|
||||
return `<div class="bar-row">
|
||||
<span class="bar-label">${label}</span>
|
||||
<div class="bar-bg"><div class="bar-fill" style="width:${v}%;background:${barColor(v)}"></div></div>
|
||||
<span class="bar-val">${v}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const HUNGER = {4:'repu',3:'rassasié',2:'petit creux',1:'faim',0:'affamé'};
|
||||
const THIRST = {3:'désaltéré',2:'hydraté',1:'soif',0:'déshydraté'};
|
||||
const SLEEP = {3:'reposé',2:'fatigué',1:'éreinté',0:'épuisé'};
|
||||
|
||||
function render(data) {
|
||||
document.getElementById('meta').innerHTML =
|
||||
`Mis à jour : ${data.fetched_at ? data.fetched_at.replace('T',' ').substring(0,19) : '—'}
|
||||
| <span class="${data.ok ? 'status-ok':'status-err'}">${data.ok ? '● CONNECTÉ' : '✖ ERREUR DB'}</span>`;
|
||||
|
||||
if (!data.ok) {
|
||||
document.getElementById('session-content').innerHTML = `<span style="color:red">${data.error}</span>`;
|
||||
return;
|
||||
}
|
||||
|
||||
// -- Session --
|
||||
const s = data.session;
|
||||
const phaseClass = s.phase === 'JOUR' ? 'phase-jour' : 'phase-nuit';
|
||||
const phaseIcon = s.phase === 'JOUR' ? '☀' : '🌙';
|
||||
document.getElementById('session-content').innerHTML = `
|
||||
<div style="text-align:center;margin-bottom:10px">
|
||||
<span class="${phaseClass}">${phaseIcon} ${s.phase}</span>
|
||||
</div>
|
||||
<div class="session-row"><span class="label">Jour</span><span>${s.day}</span></div>
|
||||
<div class="session-row"><span class="label">Tick</span><span>${String(s.tick).padStart(2,'0')}h00</span></div>
|
||||
<div class="session-row"><span class="label">Mode</span><span class="mode-${s.mode}">${s.mode.toUpperCase()}</span></div>
|
||||
<div class="session-row"><span class="label">Status</span><span>${s.status}</span></div>
|
||||
<div class="session-row"><span class="label">Seed</span><span>${s.seed}</span></div>
|
||||
<div class="session-row"><span class="label">Session</span><span style="color:#888;font-size:10px">#${s.id}</span></div>
|
||||
`;
|
||||
|
||||
// -- PNJ --
|
||||
const pnjHtml = (data.characters || []).map(c => {
|
||||
const st = c.state || {};
|
||||
const sv = (st.last_actions && !Array.isArray(st.last_actions)) ? st.last_actions : {};
|
||||
const mood = st.mood || 'neutre';
|
||||
const hp_pct = Math.round((c.hp / c.max_hp) * 100);
|
||||
return `<div class="pnj">
|
||||
<div><span class="pnj-name">${c.name}</span>
|
||||
<span class="pnj-mood mood-${mood}">${mood}</span>
|
||||
${c.is_in_shelter ? '<span style="color:#4fc3f7;font-size:10px;margin-left:6px">ABRI</span>' : ''}
|
||||
</div>
|
||||
<div class="pnj-row">
|
||||
HP <div class="hp-bar"><div class="hp-fill" style="width:${hp_pct}%"></div></div>${c.hp}/${c.max_hp}
|
||||
| Caps: ${c.caps}
|
||||
| Stress: ${st.stress_level ?? '—'}
|
||||
| Fatigue: ${sv.fatigue ?? 0}
|
||||
</div>
|
||||
<div class="pnj-row" style="color:#777;font-size:10px">
|
||||
🍖${HUNGER[sv.hunger??4]} 💧${THIRST[sv.thirst??3]} 😴${SLEEP[sv.sleep??3]}
|
||||
</div>
|
||||
<div class="pnj-row" style="color:#555">${c.location_slug || '—'} · ${c.faction_slug || '—'}</div>
|
||||
</div>`;
|
||||
}).join('') || '<span style="color:#555">Aucun PNJ</span>';
|
||||
document.getElementById('pnj-content').innerHTML = pnjHtml;
|
||||
|
||||
// -- World --
|
||||
const worldHtml = (data.world || []).map(w =>
|
||||
`<div class="loc">
|
||||
<div class="loc-name">${w.location_slug} <span style="color:#555;font-size:10px">pop:${w.pop_count}</span></div>
|
||||
${renderBar('Eau', w.water_level)}
|
||||
${renderBar('Nourriture', w.food_level)}
|
||||
${renderBar('Sécurité', w.security_level)}
|
||||
</div>`
|
||||
).join('') || '<span style="color:#555">Aucune donnée</span>';
|
||||
document.getElementById('world-content').innerHTML = worldHtml;
|
||||
|
||||
// -- Events --
|
||||
const evHtml = (data.events || []).map(e =>
|
||||
`<div class="event">
|
||||
<span class="event-time">J${String(e.day).padStart(3,'0')} T${String(e.tick).padStart(2,'0')}h</span>
|
||||
<span class="event-type">[${e.event_type}]</span>
|
||||
${e.description}
|
||||
</div>`
|
||||
).join('') || '<span style="color:#555">Aucun event</span>';
|
||||
document.getElementById('events-content').innerHTML = evHtml;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
const r = await fetch('/api/state');
|
||||
const data = await r.json();
|
||||
render(data);
|
||||
} catch(e) {
|
||||
document.getElementById('meta').innerHTML = `<span class="status-err">✖ Serveur dashboard inaccessible</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
refresh();
|
||||
setInterval(refresh, 3000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
if self.path == "/api/state":
|
||||
data = json.dumps(get_state(), default=str).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", len(data))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
elif self.path in ("/", "/index.html"):
|
||||
page = HTML.encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", len(page))
|
||||
self.end_headers()
|
||||
self.wfile.write(page)
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
def log_message(self, *args):
|
||||
pass # silence les logs HTTP
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
port = int(os.getenv("DASHBOARD_PORT", 8765))
|
||||
print(f"Dashboard → http://localhost:{port}")
|
||||
HTTPServer(("0.0.0.0", port), Handler).serve_forever()
|
||||
@@ -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
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)}")
|
||||
@@ -0,0 +1 @@
|
||||
psycopg2-binary>=2.9
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"}
|
||||
@@ -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":"..."}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user