Skip to main content

fforge_core/
worldgen.rs

1//! Seeded world generation. Lives at the **edge**: it runs once at new-game
2//! time and its *output* (the `World`) is recorded into `GameStarted` — the
3//! fold never re-derives it, so worldgen can evolve freely without breaking
4//! saves (the record-resolved-values principle).
5
6use crate::rng::{derive_stream, Rng};
7use crate::schedule::double_round_robin;
8use fforge_domain::{
9    best_role, Attribute, Attributes, Character, Club, ClubId, Competition, CompetitionId,
10    Fixture, GameDate, Player, PlayerId, Role, Staff, StaffId, StaffRole, World, NUM_ATTRIBUTES,
11    ROLE_WEIGHTS,
12};
13use std::collections::BTreeMap;
14
15pub struct WorldGenConfig {
16    pub num_clubs: usize,
17    pub start_year: i32,
18    pub league_name: String,
19}
20
21impl Default for WorldGenConfig {
22    fn default() -> Self {
23        WorldGenConfig {
24            num_clubs: 20,
25            start_year: 2026,
26            league_name: "Prima Divisione".to_string(),
27        }
28    }
29}
30
31const WORLDGEN_STREAM: u64 = 0x574F_524C_4447_454E; // "WORLDGEN"
32
33/// Squad template: role → headcount. 24 players per club.
34const SQUAD_TEMPLATE: [(Role, usize); 8] = [
35    (Role::Gk, 3),
36    (Role::Cb, 4),
37    (Role::Fb, 4),
38    (Role::Dm, 2),
39    (Role::Cm, 3),
40    (Role::Am, 2),
41    (Role::W, 3),
42    (Role::St, 3),
43];
44
45pub fn generate(seed: u64, cfg: &WorldGenConfig) -> (World, Vec<Fixture>, GameDate) {
46    assert!(cfg.num_clubs >= 2 && cfg.num_clubs % 2 == 0);
47    let mut rng = derive_stream(seed, WORLDGEN_STREAM);
48    let start_date = GameDate::from_year_day(cfg.start_year, 220); // late-summer kickoff
49
50    // Club quality anchors, evenly spread then shuffled: a league with a
51    // clear top, middle, and relegation-fodder bottom.
52    let mut qualities: Vec<f64> = (0..cfg.num_clubs)
53        .map(|i| 48.0 + 26.0 * i as f64 / (cfg.num_clubs - 1) as f64)
54        .collect();
55    rng.shuffle(&mut qualities);
56
57    let mut players = BTreeMap::new();
58    let mut clubs = BTreeMap::new();
59    let mut staff = BTreeMap::new();
60    let mut club_ids = Vec::with_capacity(cfg.num_clubs);
61    let mut next_player = 0u32;
62    let mut next_staff = 0u32;
63    let mut used_club_names: Vec<String> = Vec::new();
64
65    for (ci, &quality) in qualities.iter().enumerate() {
66        let club_id = ClubId(ci as u16);
67        club_ids.push(club_id);
68        let name = unique_club_name(&mut rng, &mut used_club_names);
69
70        let mut squad = Vec::new();
71        for &(role, count) in &SQUAD_TEMPLATE {
72            for _ in 0..count {
73                let id = PlayerId(next_player);
74                next_player += 1;
75                let player = gen_player(&mut rng, id, role, quality, start_date);
76                squad.push(id);
77                players.insert(id, player);
78            }
79        }
80
81        let manager_id = StaffId(next_staff);
82        next_staff += 1;
83        staff.insert(
84            manager_id,
85            Staff {
86                id: manager_id,
87                name: person_name(&mut rng),
88                role: StaffRole::Manager,
89                club: Some(club_id),
90            },
91        );
92
93        clubs.insert(
94            club_id,
95            Club {
96                id: club_id,
97                name,
98                players: squad,
99            },
100        );
101    }
102
103    let competition = Competition {
104        id: CompetitionId(0),
105        name: cfg.league_name.clone(),
106        clubs: club_ids.clone(),
107    };
108    let schedule = double_round_robin(&club_ids);
109
110    (
111        World {
112            players,
113            clubs,
114            staff,
115            competition,
116        },
117        schedule,
118        start_date,
119    )
120}
121
122fn gen_player(rng: &mut Rng, id: PlayerId, role: Role, club_quality: f64, today: GameDate) -> Player {
123    // Age ~ triangular 16..=36, centered mid-20s.
124    let age = 16 + ((rng.f64() + rng.f64()) * 10.0) as i32;
125    let birth = today
126        .add_days(-(age as i64) * fforge_domain::date::DAYS_PER_YEAR)
127        .add_days(-(rng.below(365) as i64));
128
129    // Player quality center around the club's anchor; youth discounted a bit
130    // (their headroom lives in PA instead).
131    let youth_discount = if age < 21 { (21 - age) as f64 * 2.0 } else { 0.0 };
132    let base = rng.normal(club_quality - youth_discount, 5.0).clamp(28.0, 92.0);
133
134    // Shape attributes by the role-weighting table: weight 5 ⇒ well above the
135    // player's base, weight 1 ⇒ well below, weight 0 ⇒ untrained floor.
136    let mut values = [0u8; NUM_ATTRIBUTES];
137    for attr in Attribute::ALL {
138        let w = ROLE_WEIGHTS.weight(role, attr);
139        let v = if w == 0 {
140            rng.range_i32(3, 18) as f64
141        } else {
142            rng.normal(base + (w as f64 - 3.0) * 4.5, 4.5)
143        };
144        values[attr.index()] = v.clamp(1.0, 96.0) as u8;
145    }
146    let attributes = Attributes::new(values);
147
148    // PA: young players get real headroom; veterans are what they are.
149    let (_, best_ca) = best_role(&attributes, &ROLE_WEIGHTS);
150    let headroom = if age < 24 {
151        (24 - age) * 2 + rng.range_i32(0, 8)
152    } else {
153        rng.range_i32(0, 3)
154    };
155    let potential = (best_ca as i32 + headroom).clamp(best_ca as i32, 97) as u8;
156
157    let character = Character {
158        potential,
159        determination: rng.range_i32(20, 95) as u8,
160        professionalism: rng.range_i32(20, 95) as u8,
161        consistency: rng.range_i32(25, 90) as u8,
162        injury_proneness: rng.range_i32(5, 85) as u8,
163        leadership: rng.range_i32(10, 90) as u8,
164    };
165
166    Player {
167        id,
168        name: person_name(rng),
169        birth,
170        natural_role: role,
171        attributes,
172        character,
173    }
174}
175
176// ---------- name generation (flavor only; all seeded) ----------
177
178const FIRST_NAMES: &[&str] = &[
179    "Luca", "Marco", "Andrea", "Matteo", "Davide", "Giorgio", "Paolo", "Sandro", "Enzo", "Dario",
180    "Bruno", "Franco", "Nicola", "Sergio", "Tommaso", "Aldo", "Pietro", "Emil", "Jonas", "Karl",
181    "Sven", "Anders", "Milan", "Ivan", "Josip", "Luka", "Marko", "Petar", "Diego", "Rafael",
182    "Thiago", "Bruno", "João", "Pedro", "Nuno", "Rui", "Sami", "Kofi", "Yaya", "Ousmane",
183    "Ibrahim", "Amadou", "Kenji", "Hiro", "Sota", "Owen", "Harry", "Callum",
184];
185
186const LAST_NAMES: &[&str] = &[
187    "Rossi", "Bianchi", "Ferrari", "Colombo", "Ricci", "Marino", "Greco", "Conti", "Gallo",
188    "Fontana", "Moretti", "Barbieri", "Santoro", "Rinaldi", "Vitale", "Longo", "Serra", "Farina",
189    "Berg", "Lund", "Dahl", "Novak", "Horvat", "Kovac", "Babic", "Silva", "Santos", "Costa",
190    "Pereira", "Almeida", "Carvalho", "Mensah", "Diallo", "Traoré", "Keita", "Tanaka", "Sato",
191    "Mori", "Ward", "Hughes", "Walsh", "Kane", "Duarte", "Vega", "Morales", "Iglesias", "Reyes",
192    "Ortega",
193];
194
195fn person_name(rng: &mut Rng) -> String {
196    let first = FIRST_NAMES[rng.below(FIRST_NAMES.len() as u32) as usize];
197    let last = LAST_NAMES[rng.below(LAST_NAMES.len() as u32) as usize];
198    format!("{first} {last}")
199}
200
201const CLUB_PREFIXES: &[&str] = &[
202    "AC", "FC", "US", "Real", "Sporting", "Atlético", "Union", "Inter", "Olimpia", "Racing",
203    "Dinamo", "Virtus",
204];
205
206const CITY_STEMS: &[&str] = &[
207    "Vald", "Mont", "Torr", "Fior", "Cast", "Port", "Riv", "Sald", "Ver", "Bell", "Camp", "Sor",
208    "Lav", "Ner", "Pral", "Ost",
209];
210
211const CITY_ENDS: &[&str] = &[
212    "emona", "averde", "isola", "entino", "ora", "ana", "etto", "iano", "aro", "onte", "urnia",
213    "essa",
214];
215
216fn unique_club_name(rng: &mut Rng, used: &mut Vec<String>) -> String {
217    loop {
218        let prefix = CLUB_PREFIXES[rng.below(CLUB_PREFIXES.len() as u32) as usize];
219        let city = format!(
220            "{}{}",
221            CITY_STEMS[rng.below(CITY_STEMS.len() as u32) as usize],
222            CITY_ENDS[rng.below(CITY_ENDS.len() as u32) as usize]
223        );
224        let name = format!("{prefix} {city}");
225        if !used.contains(&name) {
226            used.push(name.clone());
227            return name;
228        }
229    }
230}