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.is_multiple_of(2));
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 used_club_names: Vec<String> = Vec::new();
63
64    for (ci, &quality) in qualities.iter().enumerate() {
65        let club_id = ClubId(ci as u16);
66        club_ids.push(club_id);
67        let name = unique_club_name(&mut rng, &mut used_club_names);
68
69        let mut squad = Vec::new();
70        for &(role, count) in &SQUAD_TEMPLATE {
71            for _ in 0..count {
72                let id = PlayerId(next_player);
73                next_player += 1;
74                let player = gen_player(&mut rng, id, role, quality, start_date);
75                squad.push(id);
76                players.insert(id, player);
77            }
78        }
79
80        let manager_id = StaffId(ci as u32);
81        staff.insert(
82            manager_id,
83            Staff {
84                id: manager_id,
85                name: person_name(&mut rng),
86                role: StaffRole::Manager,
87                club: Some(club_id),
88            },
89        );
90
91        clubs.insert(
92            club_id,
93            Club {
94                id: club_id,
95                name,
96                players: squad,
97            },
98        );
99    }
100
101    let competition = Competition {
102        id: CompetitionId(0),
103        name: cfg.league_name.clone(),
104        clubs: club_ids.clone(),
105    };
106    let schedule = double_round_robin(&club_ids);
107
108    (
109        World {
110            players,
111            clubs,
112            staff,
113            competition,
114        },
115        schedule,
116        start_date,
117    )
118}
119
120fn gen_player(rng: &mut Rng, id: PlayerId, role: Role, club_quality: f64, today: GameDate) -> Player {
121    // Age ~ triangular 16..=36, centered mid-20s.
122    let age = 16 + ((rng.f64() + rng.f64()) * 10.0) as i32;
123    let birth = today
124        .add_days(-(age as i64) * fforge_domain::date::DAYS_PER_YEAR)
125        .add_days(-(rng.below(365) as i64));
126
127    // Player quality center around the club's anchor; youth discounted a bit
128    // (their headroom lives in PA instead).
129    let youth_discount = if age < 21 { (21 - age) as f64 * 2.0 } else { 0.0 };
130    let base = rng.normal(club_quality - youth_discount, 5.0).clamp(28.0, 92.0);
131
132    // Shape attributes by the role-weighting table: weight 5 ⇒ well above the
133    // player's base, weight 1 ⇒ well below, weight 0 ⇒ untrained floor.
134    let mut values = [0u8; NUM_ATTRIBUTES];
135    for attr in Attribute::ALL {
136        let w = ROLE_WEIGHTS.weight(role, attr);
137        let v = if w == 0 {
138            rng.range_i32(3, 18) as f64
139        } else {
140            rng.normal(base + (w as f64 - 3.0) * 4.5, 4.5)
141        };
142        values[attr.index()] = v.clamp(1.0, 96.0) as u8;
143    }
144    let attributes = Attributes::new(values);
145
146    // PA: young players get real headroom; veterans are what they are.
147    let (_, best_ca) = best_role(&attributes, &ROLE_WEIGHTS);
148    let headroom = if age < 24 {
149        (24 - age) * 2 + rng.range_i32(0, 8)
150    } else {
151        rng.range_i32(0, 3)
152    };
153    let potential = (best_ca as i32 + headroom).clamp(best_ca as i32, 97) as u8;
154
155    let character = Character {
156        potential,
157        determination: rng.range_i32(20, 95) as u8,
158        professionalism: rng.range_i32(20, 95) as u8,
159        consistency: rng.range_i32(25, 90) as u8,
160        injury_proneness: rng.range_i32(5, 85) as u8,
161        leadership: rng.range_i32(10, 90) as u8,
162    };
163
164    Player {
165        id,
166        name: person_name(rng),
167        birth,
168        natural_role: role,
169        attributes,
170        character,
171    }
172}
173
174// ---------- name generation (flavor only; all seeded) ----------
175
176const FIRST_NAMES: &[&str] = &[
177    "Luca", "Marco", "Andrea", "Matteo", "Davide", "Giorgio", "Paolo", "Sandro", "Enzo", "Dario",
178    "Bruno", "Franco", "Nicola", "Sergio", "Tommaso", "Aldo", "Pietro", "Emil", "Jonas", "Karl",
179    "Sven", "Anders", "Milan", "Ivan", "Josip", "Luka", "Marko", "Petar", "Diego", "Rafael",
180    "Thiago", "Bruno", "João", "Pedro", "Nuno", "Rui", "Sami", "Kofi", "Yaya", "Ousmane",
181    "Ibrahim", "Amadou", "Kenji", "Hiro", "Sota", "Owen", "Harry", "Callum",
182];
183
184const LAST_NAMES: &[&str] = &[
185    "Rossi", "Bianchi", "Ferrari", "Colombo", "Ricci", "Marino", "Greco", "Conti", "Gallo",
186    "Fontana", "Moretti", "Barbieri", "Santoro", "Rinaldi", "Vitale", "Longo", "Serra", "Farina",
187    "Berg", "Lund", "Dahl", "Novak", "Horvat", "Kovac", "Babic", "Silva", "Santos", "Costa",
188    "Pereira", "Almeida", "Carvalho", "Mensah", "Diallo", "Traoré", "Keita", "Tanaka", "Sato",
189    "Mori", "Ward", "Hughes", "Walsh", "Kane", "Duarte", "Vega", "Morales", "Iglesias", "Reyes",
190    "Ortega",
191];
192
193fn person_name(rng: &mut Rng) -> String {
194    let first = FIRST_NAMES[rng.below(FIRST_NAMES.len() as u32) as usize];
195    let last = LAST_NAMES[rng.below(LAST_NAMES.len() as u32) as usize];
196    format!("{first} {last}")
197}
198
199const CLUB_PREFIXES: &[&str] = &[
200    "AC", "FC", "US", "Real", "Sporting", "Atlético", "Union", "Inter", "Olimpia", "Racing",
201    "Dinamo", "Virtus",
202];
203
204const CITY_STEMS: &[&str] = &[
205    "Vald", "Mont", "Torr", "Fior", "Cast", "Port", "Riv", "Sald", "Ver", "Bell", "Camp", "Sor",
206    "Lav", "Ner", "Pral", "Ost",
207];
208
209const CITY_ENDS: &[&str] = &[
210    "emona", "averde", "isola", "entino", "ora", "ana", "etto", "iano", "aro", "onte", "urnia",
211    "essa",
212];
213
214fn unique_club_name(rng: &mut Rng, used: &mut Vec<String>) -> String {
215    loop {
216        let prefix = CLUB_PREFIXES[rng.below(CLUB_PREFIXES.len() as u32) as usize];
217        let city = format!(
218            "{}{}",
219            CITY_STEMS[rng.below(CITY_STEMS.len() as u32) as usize],
220            CITY_ENDS[rng.below(CITY_ENDS.len() as u32) as usize]
221        );
222        let name = format!("{prefix} {city}");
223        if !used.contains(&name) {
224            used.push(name.clone());
225            return name;
226        }
227    }
228}