pykworldsim 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pykworldsim/__init__.py +10 -0
- pykworldsim/event.py +28 -0
- pykworldsim/goal.py +34 -0
- pykworldsim/job.py +34 -0
- pykworldsim/location.py +31 -0
- pykworldsim/person.py +468 -0
- pykworldsim/relationship.py +97 -0
- pykworldsim/report.py +101 -0
- pykworldsim/world.py +391 -0
- pykworldsim-0.1.0.dist-info/METADATA +153 -0
- pykworldsim-0.1.0.dist-info/RECORD +14 -0
- pykworldsim-0.1.0.dist-info/WHEEL +5 -0
- pykworldsim-0.1.0.dist-info/licenses/LICENSE +21 -0
- pykworldsim-0.1.0.dist-info/top_level.txt +1 -0
pykworldsim/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from .world import World
|
|
2
|
+
from .person import Person
|
|
3
|
+
from .relationship import Relationship
|
|
4
|
+
from .goal import Goal
|
|
5
|
+
from .event import Event
|
|
6
|
+
from .location import Location
|
|
7
|
+
from .job import Job
|
|
8
|
+
|
|
9
|
+
__all__ = ["World", "Person", "Relationship", "Goal", "Event", "Location", "Job"]
|
|
10
|
+
__version__ = "1.0.0"
|
pykworldsim/event.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import random
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Optional, Callable
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class Event:
|
|
8
|
+
"""A world event that fires probabilistically or on demand."""
|
|
9
|
+
Name: str
|
|
10
|
+
Probability: float = 0.05 # per-year probability
|
|
11
|
+
Effect: Optional[Callable] = None # fn(world, year) → None
|
|
12
|
+
Description: str = ""
|
|
13
|
+
IsGlobal: bool = True # True = affects everyone; False = individual
|
|
14
|
+
|
|
15
|
+
def ShouldTrigger(self) -> bool:
|
|
16
|
+
return random.random() < self.Probability
|
|
17
|
+
|
|
18
|
+
def Fire(self, World, Year: int):
|
|
19
|
+
Msg = f"[Year {Year}] EVENT: {self.Name}"
|
|
20
|
+
if self.Description:
|
|
21
|
+
Msg += f" — {self.Description}"
|
|
22
|
+
World.Log.append(Msg)
|
|
23
|
+
if self.Effect:
|
|
24
|
+
self.Effect(World, Year)
|
|
25
|
+
|
|
26
|
+
def __str__(self):
|
|
27
|
+
Scope = "Global" if self.IsGlobal else "Local"
|
|
28
|
+
return f"Event: {self.Name} ({self.Probability*100:.1f}%) - {Scope}"
|
pykworldsim/goal.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
class Goal:
|
|
2
|
+
"""Represents a personal goal with a type and priority."""
|
|
3
|
+
|
|
4
|
+
ValidGoalTypes = {
|
|
5
|
+
"GetJob", "GetPromotion", "MakeFriends", "FindPartner",
|
|
6
|
+
"ImproveSkills", "EarnMoney", "IncreaseStatus", "MoveCity",
|
|
7
|
+
"StartFamily", "BuildBusiness", "SocializeMore", "FindPurpose"
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
def __init__(self, GoalType: str, Priority: float = 0.5):
|
|
11
|
+
if GoalType not in self.ValidGoalTypes:
|
|
12
|
+
raise ValueError(f"GoalType '{GoalType}' is not valid. Choose from: {sorted(self.ValidGoalTypes)}")
|
|
13
|
+
if not (0.0 <= Priority <= 1.0):
|
|
14
|
+
raise ValueError("Priority must be between 0.0 and 1.0")
|
|
15
|
+
self.GoalType = GoalType
|
|
16
|
+
self.Priority = Priority
|
|
17
|
+
self.Progress = 0.0
|
|
18
|
+
self.Achieved = False
|
|
19
|
+
self.YearAchieved = None
|
|
20
|
+
|
|
21
|
+
def UpdateProgress(self, Delta: float):
|
|
22
|
+
self.Progress = max(0.0, min(1.0, self.Progress + Delta))
|
|
23
|
+
if self.Progress >= 1.0 and not self.Achieved:
|
|
24
|
+
self.Achieved = True
|
|
25
|
+
return True
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
def __repr__(self):
|
|
29
|
+
Status = "✓" if self.Achieved else f"{self.Progress:.0%}"
|
|
30
|
+
return f"Goal({self.GoalType}, priority={self.Priority:.1f}, progress={Status})"
|
|
31
|
+
|
|
32
|
+
def __str__(self):
|
|
33
|
+
Status = "Achieved" if self.Achieved else f"{self.Progress:.0%} Complete"
|
|
34
|
+
return f"Goal: {self.GoalType} ({Status})"
|
pykworldsim/job.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
class Job:
|
|
2
|
+
"""A job that can be held by a person."""
|
|
3
|
+
|
|
4
|
+
def __init__(self, Role: str, Salary: float = 50000,
|
|
5
|
+
Prestige: float = 0.5, StressLevel: float = 0.4,
|
|
6
|
+
SkillRequired: float = 0.3, Industry: str = "general"):
|
|
7
|
+
self.Role = Role
|
|
8
|
+
self.Salary = Salary
|
|
9
|
+
self.Prestige = max(0.0, min(1.0, Prestige))
|
|
10
|
+
self.StressLevel = max(0.0, min(1.0, StressLevel))
|
|
11
|
+
self.SkillRequired = max(0.0, min(1.0, SkillRequired))
|
|
12
|
+
self.Industry = Industry
|
|
13
|
+
self.Holder = None
|
|
14
|
+
self.Location = None
|
|
15
|
+
|
|
16
|
+
def IsAvailable(self) -> bool:
|
|
17
|
+
return self.Holder is None
|
|
18
|
+
|
|
19
|
+
def Assign(self, Person):
|
|
20
|
+
self.Holder = Person
|
|
21
|
+
|
|
22
|
+
def Vacate(self):
|
|
23
|
+
self.Holder = None
|
|
24
|
+
|
|
25
|
+
def AnnualSalary(self, Year: int = 0) -> float:
|
|
26
|
+
"""Salary grows slightly with experience."""
|
|
27
|
+
return self.Salary * (1.02 ** Year)
|
|
28
|
+
|
|
29
|
+
def __repr__(self):
|
|
30
|
+
Status = f"held by {self.Holder.Name}" if self.Holder else "available"
|
|
31
|
+
return f"Job({self.Role}, salary={self.Salary:,.0f}, {Status})"
|
|
32
|
+
|
|
33
|
+
def __str__(self):
|
|
34
|
+
return f"{self.Role} (${self.Salary:,.0f}/yr) - {'Available' if not self.Holder else f'Held by {self.Holder.Name}'}"
|
pykworldsim/location.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
class Location:
|
|
2
|
+
"""A place in the world where people live, work, and interact."""
|
|
3
|
+
|
|
4
|
+
def __init__(self, Name: str, LocationType: str = "city",
|
|
5
|
+
OpportunityLevel: float = 0.5,
|
|
6
|
+
CostOfLiving: float = 0.5,
|
|
7
|
+
PopulationDensity: float = 0.5):
|
|
8
|
+
self.Name = Name
|
|
9
|
+
self.LocationType = LocationType # city, suburb, rural, campus
|
|
10
|
+
self.OpportunityLevel = max(0.0, min(1.0, OpportunityLevel))
|
|
11
|
+
self.CostOfLiving = max(0.0, min(1.0, CostOfLiving))
|
|
12
|
+
self.PopulationDensity = max(0.0, min(1.0, PopulationDensity))
|
|
13
|
+
self.Residents: list = []
|
|
14
|
+
self.Jobs: list = []
|
|
15
|
+
|
|
16
|
+
def AddResident(self, Person):
|
|
17
|
+
if Person not in self.Residents:
|
|
18
|
+
self.Residents.append(Person)
|
|
19
|
+
|
|
20
|
+
def RemoveResident(self, Person):
|
|
21
|
+
if Person in self.Residents:
|
|
22
|
+
self.Residents.remove(Person)
|
|
23
|
+
|
|
24
|
+
def AddJob(self, Job):
|
|
25
|
+
self.Jobs.append(Job)
|
|
26
|
+
|
|
27
|
+
def __repr__(self):
|
|
28
|
+
return f"Location({self.Name}, type={self.LocationType}, residents={len(self.Residents)})"
|
|
29
|
+
|
|
30
|
+
def __str__(self):
|
|
31
|
+
return f"{self.Name} ({self.LocationType.title()}) - Population: {len(self.Residents)}"
|
pykworldsim/person.py
ADDED
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
import random
|
|
2
|
+
import math
|
|
3
|
+
from typing import Dict, List, Optional, Any
|
|
4
|
+
from .goal import Goal
|
|
5
|
+
from .relationship import Relationship
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Person:
|
|
9
|
+
"""
|
|
10
|
+
A simulated person with traits, goals, internal state, and social connections.
|
|
11
|
+
All variable names use PascalCase.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
_IdCounter = 0
|
|
15
|
+
|
|
16
|
+
# Default trait distributions if not specified
|
|
17
|
+
_DefaultTraitRanges: Dict[str, tuple] = {
|
|
18
|
+
"Openness": (0.2, 0.9),
|
|
19
|
+
"Conscientiousness": (0.2, 0.9),
|
|
20
|
+
"Extraversion": (0.1, 0.9),
|
|
21
|
+
"Agreeableness": (0.2, 0.9),
|
|
22
|
+
"Neuroticism": (0.1, 0.8),
|
|
23
|
+
"Ambition": (0.1, 0.9),
|
|
24
|
+
"Intelligence": (0.2, 0.9),
|
|
25
|
+
"SocialSkill": (0.2, 0.9),
|
|
26
|
+
"Creativity": (0.1, 0.9),
|
|
27
|
+
"RiskTolerance": (0.1, 0.9),
|
|
28
|
+
"Discipline": (0.1, 0.9),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
def __init__(self, Name: str, Age: int = 20,
|
|
32
|
+
Traits: Optional[Dict[str, float]] = None,
|
|
33
|
+
Location: Optional[Any] = None):
|
|
34
|
+
Person._IdCounter += 1
|
|
35
|
+
self.Id = Person._IdCounter
|
|
36
|
+
self.Name = Name
|
|
37
|
+
self.Age = Age
|
|
38
|
+
self.Location = Location
|
|
39
|
+
self.IsAlive = True
|
|
40
|
+
|
|
41
|
+
# --- Traits (static-ish personality) ---
|
|
42
|
+
self.Traits: Dict[str, float] = {}
|
|
43
|
+
for TraitName, (Lo, Hi) in self._DefaultTraitRanges.items():
|
|
44
|
+
self.Traits[TraitName] = round(random.uniform(Lo, Hi), 3)
|
|
45
|
+
if Traits:
|
|
46
|
+
for K, V in Traits.items():
|
|
47
|
+
NormKey = self._NormalizeTraitKey(K)
|
|
48
|
+
self.Traits[NormKey] = max(0.0, min(1.0, float(V)))
|
|
49
|
+
|
|
50
|
+
# --- Internal state (dynamic) ---
|
|
51
|
+
self.Mood = random.uniform(0.4, 0.7)
|
|
52
|
+
self.Energy = random.uniform(0.5, 0.9)
|
|
53
|
+
self.Stress = random.uniform(0.1, 0.4)
|
|
54
|
+
self.Motivation = random.uniform(0.4, 0.8)
|
|
55
|
+
self.LifeSatisfaction = random.uniform(0.3, 0.7)
|
|
56
|
+
|
|
57
|
+
# --- Life metrics ---
|
|
58
|
+
self.Income = 0.0
|
|
59
|
+
self.Savings = 0.0
|
|
60
|
+
self.Status = random.uniform(0.1, 0.4)
|
|
61
|
+
self.Education = random.uniform(0.1, 0.4)
|
|
62
|
+
self.SkillLevel = random.uniform(0.1, 0.4)
|
|
63
|
+
self.HasDegree = False
|
|
64
|
+
|
|
65
|
+
# --- Social ---
|
|
66
|
+
self.Relationships: Dict[int, Relationship] = {} # Other person's Id → Relationship
|
|
67
|
+
self.RomanticPartner: Optional["Person"] = None
|
|
68
|
+
self.Children: List["Person"] = []
|
|
69
|
+
self.Parents: List["Person"] = []
|
|
70
|
+
|
|
71
|
+
# --- Goals ---
|
|
72
|
+
self.Goals: List[Goal] = []
|
|
73
|
+
self._GenerateInitialGoals()
|
|
74
|
+
|
|
75
|
+
# --- Career ---
|
|
76
|
+
self.Job: Optional[Any] = None
|
|
77
|
+
self.YearsAtCurrentJob: int = 0
|
|
78
|
+
self.CareerHistory: List[str] = []
|
|
79
|
+
|
|
80
|
+
# --- Life log ---
|
|
81
|
+
self.LifeLog: List[str] = []
|
|
82
|
+
|
|
83
|
+
# --- Tracking ---
|
|
84
|
+
self.HappinessHistory: List[float] = []
|
|
85
|
+
self.IncomeHistory: List[float] = []
|
|
86
|
+
self.YearBorn: int = 0 # Set by World
|
|
87
|
+
|
|
88
|
+
# ------------------------------------------------------------------
|
|
89
|
+
# Trait normalization
|
|
90
|
+
# ------------------------------------------------------------------
|
|
91
|
+
def _NormalizeTraitKey(self, Key: str) -> str:
|
|
92
|
+
"""Normalise user-supplied trait keys to PascalCase canonical names."""
|
|
93
|
+
Mapping = {
|
|
94
|
+
"openness": "Openness",
|
|
95
|
+
"conscientiousness": "Conscientiousness",
|
|
96
|
+
"extraversion": "Extraversion",
|
|
97
|
+
"agreeableness": "Agreeableness",
|
|
98
|
+
"neuroticism": "Neuroticism",
|
|
99
|
+
"ambition": "Ambition",
|
|
100
|
+
"intelligence": "Intelligence",
|
|
101
|
+
"socialskill": "SocialSkill",
|
|
102
|
+
"social": "SocialSkill",
|
|
103
|
+
"creativity": "Creativity",
|
|
104
|
+
"risktolerance": "RiskTolerance",
|
|
105
|
+
"risk": "RiskTolerance",
|
|
106
|
+
"discipline": "Discipline",
|
|
107
|
+
}
|
|
108
|
+
return Mapping.get(Key.lower().replace("_", ""), Key)
|
|
109
|
+
|
|
110
|
+
# ------------------------------------------------------------------
|
|
111
|
+
# Goals
|
|
112
|
+
# ------------------------------------------------------------------
|
|
113
|
+
def _GenerateInitialGoals(self):
|
|
114
|
+
Ambition = self.Traits.get("Ambition", 0.5)
|
|
115
|
+
Social = self.Traits.get("SocialSkill", 0.5)
|
|
116
|
+
if Ambition > 0.6:
|
|
117
|
+
self.Goals.append(Goal("GetJob", Priority=0.9))
|
|
118
|
+
self.Goals.append(Goal("GetPromotion", Priority=0.7))
|
|
119
|
+
self.Goals.append(Goal("EarnMoney", Priority=0.7))
|
|
120
|
+
else:
|
|
121
|
+
self.Goals.append(Goal("GetJob", Priority=0.6))
|
|
122
|
+
if Social > 0.5:
|
|
123
|
+
self.Goals.append(Goal("MakeFriends", Priority=0.7))
|
|
124
|
+
if self.Traits.get("Openness", 0.5) > 0.6:
|
|
125
|
+
self.Goals.append(Goal("ImproveSkills", Priority=0.5))
|
|
126
|
+
self.Goals.append(Goal("FindPartner", Priority=round(random.uniform(0.3, 0.8), 2)))
|
|
127
|
+
|
|
128
|
+
def AddGoal(self, GoalType: str, Priority: float = 0.5):
|
|
129
|
+
self.Goals.append(Goal(GoalType, Priority))
|
|
130
|
+
|
|
131
|
+
# ------------------------------------------------------------------
|
|
132
|
+
# Core simulation tick
|
|
133
|
+
# ------------------------------------------------------------------
|
|
134
|
+
def Tick(self, Year: int, World: Any):
|
|
135
|
+
if not self.IsAlive:
|
|
136
|
+
return
|
|
137
|
+
self.Age += 1
|
|
138
|
+
self._UpdateInternalState()
|
|
139
|
+
self._MakeDecisions(Year, World)
|
|
140
|
+
self._UpdateGoals(Year)
|
|
141
|
+
self._CheckMortality(Year, World)
|
|
142
|
+
self.HappinessHistory.append(round(self.LifeSatisfaction, 3))
|
|
143
|
+
self.IncomeHistory.append(round(self.Income, 2))
|
|
144
|
+
|
|
145
|
+
# ------------------------------------------------------------------
|
|
146
|
+
# Internal state update
|
|
147
|
+
# ------------------------------------------------------------------
|
|
148
|
+
def _UpdateInternalState(self):
|
|
149
|
+
Neuroticism = self.Traits.get("Neuroticism", 0.4)
|
|
150
|
+
Extraversion = self.Traits.get("Extraversion", 0.5)
|
|
151
|
+
FriendCount = self._CountRelationshipsByType(["friend", "close_friend", "partner"])
|
|
152
|
+
|
|
153
|
+
# Mood fluctuates; neuroticism increases variance
|
|
154
|
+
MoodShift = random.gauss(0, 0.08 * (0.5 + Neuroticism))
|
|
155
|
+
self.Mood = max(0.0, min(1.0, self.Mood + MoodShift))
|
|
156
|
+
|
|
157
|
+
# Stress from job and lack of money
|
|
158
|
+
JobStress = self.Job.StressLevel if self.Job else 0.1
|
|
159
|
+
FinancialStress = max(0.0, 0.5 - min(1.0, self.Savings / 20000)) * 0.3
|
|
160
|
+
self.Stress = max(0.0, min(1.0, JobStress * 0.6 + FinancialStress + random.uniform(-0.05, 0.05)))
|
|
161
|
+
|
|
162
|
+
# Energy: discipline and sleep patterns
|
|
163
|
+
Discipline = self.Traits.get("Discipline", 0.5)
|
|
164
|
+
self.Energy = max(0.1, min(1.0, Discipline * 0.5 + random.uniform(0.1, 0.4)))
|
|
165
|
+
|
|
166
|
+
# Motivation: goal progress and mood
|
|
167
|
+
ActiveGoals = [G for G in self.Goals if not G.Achieved]
|
|
168
|
+
GoalFactor = 0.5 if not ActiveGoals else min(1.0, sum(G.Priority for G in ActiveGoals) / len(ActiveGoals))
|
|
169
|
+
self.Motivation = max(0.1, min(1.0, GoalFactor * 0.6 + self.Mood * 0.4))
|
|
170
|
+
|
|
171
|
+
# Life satisfaction: multi-factor
|
|
172
|
+
SocialFactor = min(1.0, FriendCount / 5.0) * Extraversion
|
|
173
|
+
CareerFactor = (self.Job.Prestige if self.Job else 0.0) * self.Traits.get("Ambition", 0.5)
|
|
174
|
+
FinanceFactor = min(1.0, self.Savings / 50000) * 0.3
|
|
175
|
+
PartnerFactor = 0.15 if self.RomanticPartner else 0.0
|
|
176
|
+
BaseSatisfaction = (SocialFactor * 0.3 + CareerFactor * 0.25 +
|
|
177
|
+
FinanceFactor + self.Mood * 0.2 + PartnerFactor)
|
|
178
|
+
self.LifeSatisfaction = max(0.0, min(1.0,
|
|
179
|
+
self.LifeSatisfaction * 0.7 + BaseSatisfaction * 0.3))
|
|
180
|
+
|
|
181
|
+
# Savings accumulate from income minus cost of living
|
|
182
|
+
if self.Income > 0:
|
|
183
|
+
LivingCost = (self.Location.CostOfLiving * 30000 if self.Location else 20000)
|
|
184
|
+
AnnualSavings = max(0, self.Income - LivingCost) * random.uniform(0.2, 0.6)
|
|
185
|
+
self.Savings += AnnualSavings
|
|
186
|
+
|
|
187
|
+
# ------------------------------------------------------------------
|
|
188
|
+
# Decision engine
|
|
189
|
+
# ------------------------------------------------------------------
|
|
190
|
+
def _MakeDecisions(self, Year: int, World: Any):
|
|
191
|
+
# Prioritise highest-priority unachieved goal
|
|
192
|
+
ActiveGoals = sorted([G for G in self.Goals if not G.Achieved],
|
|
193
|
+
key=lambda G: G.Priority, reverse=True)
|
|
194
|
+
if not ActiveGoals:
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
TopGoal = ActiveGoals[0]
|
|
198
|
+
|
|
199
|
+
if TopGoal.GoalType == "GetJob" and self.Job is None:
|
|
200
|
+
self._SeekJob(Year, World)
|
|
201
|
+
elif TopGoal.GoalType == "GetPromotion" and self.Job is not None:
|
|
202
|
+
self._SeekPromotion(Year, World)
|
|
203
|
+
elif TopGoal.GoalType == "MakeFriends":
|
|
204
|
+
self._Socialize(Year, World)
|
|
205
|
+
elif TopGoal.GoalType == "FindPartner" and self.RomanticPartner is None:
|
|
206
|
+
self._SeekPartner(Year, World)
|
|
207
|
+
elif TopGoal.GoalType == "EarnMoney":
|
|
208
|
+
self._OptimiseCareer(Year, World)
|
|
209
|
+
elif TopGoal.GoalType == "ImproveSkills":
|
|
210
|
+
self._ImproveSkills(Year, World)
|
|
211
|
+
elif TopGoal.GoalType == "MoveCity":
|
|
212
|
+
self._ConsiderMoving(Year, World)
|
|
213
|
+
|
|
214
|
+
# Always do a little socialising
|
|
215
|
+
if random.random() < self.Traits.get("Extraversion", 0.5):
|
|
216
|
+
self._Socialize(Year, World, Intensity="light")
|
|
217
|
+
|
|
218
|
+
def _SeekJob(self, Year: int, World: Any):
|
|
219
|
+
AvailableJobs = [J for J in World.Jobs if J.IsAvailable() and
|
|
220
|
+
J.SkillRequired <= self.SkillLevel + 0.2 and
|
|
221
|
+
(J.Location is None or self.Location is None or J.Location == self.Location)]
|
|
222
|
+
if not AvailableJobs:
|
|
223
|
+
return
|
|
224
|
+
# Pick best paying job within skill reach
|
|
225
|
+
BestJob = max(AvailableJobs, key=lambda J: J.Salary * (1 + self.Traits.get("Ambition", 0.5)))
|
|
226
|
+
Chance = 0.3 + self.SkillLevel * 0.4 + self.Traits.get("Intelligence", 0.5) * 0.2
|
|
227
|
+
if random.random() < Chance:
|
|
228
|
+
BestJob.Assign(self)
|
|
229
|
+
self.Job = BestJob
|
|
230
|
+
self.Income = BestJob.Salary
|
|
231
|
+
self.YearsAtCurrentJob = 0
|
|
232
|
+
self.CareerHistory.append(f"Year {Year}: Got job as {BestJob.Role} (${BestJob.Salary:,.0f})")
|
|
233
|
+
self.LifeLog.append(f"Year {Year}: Started working as a {BestJob.Role}")
|
|
234
|
+
World.Log.append(f"{self.Name} got a job as {BestJob.Role}")
|
|
235
|
+
|
|
236
|
+
def _SeekPromotion(self, Year: int, World: Any):
|
|
237
|
+
if not self.Job or self.YearsAtCurrentJob < 2:
|
|
238
|
+
return
|
|
239
|
+
PromotionChance = (self.Traits.get("Ambition", 0.5) * 0.3 +
|
|
240
|
+
self.SkillLevel * 0.3 +
|
|
241
|
+
self.Traits.get("Conscientiousness", 0.5) * 0.2 +
|
|
242
|
+
random.uniform(0, 0.2))
|
|
243
|
+
if random.random() < PromotionChance * 0.4:
|
|
244
|
+
Raise = self.Income * random.uniform(0.10, 0.25)
|
|
245
|
+
self.Income += Raise
|
|
246
|
+
self.Job.Salary += Raise
|
|
247
|
+
self.Status = min(1.0, self.Status + 0.05)
|
|
248
|
+
self.LifeLog.append(f"Year {Year}: Got promoted! Now earning ${self.Income:,.0f}")
|
|
249
|
+
World.Log.append(f"{self.Name} received a promotion (now ${self.Income:,.0f}/yr)")
|
|
250
|
+
self.YearsAtCurrentJob += 1
|
|
251
|
+
|
|
252
|
+
def _Socialize(self, Year: int, World: Any, Intensity: str = "normal"):
|
|
253
|
+
Candidates = [P for P in World.People
|
|
254
|
+
if P.Id != self.Id and P.IsAlive and
|
|
255
|
+
(P.Location == self.Location or random.random() < 0.1)]
|
|
256
|
+
if not Candidates:
|
|
257
|
+
return
|
|
258
|
+
NumMeet = 1 if Intensity == "light" else random.randint(1, 3)
|
|
259
|
+
for _ in range(NumMeet):
|
|
260
|
+
if not Candidates:
|
|
261
|
+
break
|
|
262
|
+
Other = random.choice(Candidates)
|
|
263
|
+
Candidates.remove(Other)
|
|
264
|
+
Context = random.choice(["casual", "party", "work", "support"])
|
|
265
|
+
self._InteractWith(Other, Context, Year, World)
|
|
266
|
+
|
|
267
|
+
def _InteractWith(self, Other: "Person", Context: str, Year: int, World: Any):
|
|
268
|
+
# Get or create relationship
|
|
269
|
+
if Other.Id not in self.Relationships:
|
|
270
|
+
Rel = Relationship(self, Other)
|
|
271
|
+
Rel.YearMet = Year
|
|
272
|
+
Rel.RecordEvent(Year, "met", f"{self.Name} met {Other.Name}")
|
|
273
|
+
self.Relationships[Other.Id] = Rel
|
|
274
|
+
Other.Relationships[self.Id] = Rel
|
|
275
|
+
World.Log.append(f"{self.Name} met {Other.Name}")
|
|
276
|
+
|
|
277
|
+
Rel = self.Relationships[Other.Id]
|
|
278
|
+
Rel.Interact(Context=Context, Year=Year)
|
|
279
|
+
|
|
280
|
+
# Romantic spark: if both single and good chemistry
|
|
281
|
+
if (self.RomanticPartner is None and Other.RomanticPartner is None and
|
|
282
|
+
Rel.Strength > 0.5 and Rel.Trust > 0.4 and
|
|
283
|
+
random.random() < 0.08 * self.Traits.get("Extraversion", 0.5)):
|
|
284
|
+
self._StartRomance(Other, Year, World)
|
|
285
|
+
|
|
286
|
+
def _StartRomance(self, Other: "Person", Year: int, World: Any):
|
|
287
|
+
self.RomanticPartner = Other
|
|
288
|
+
Other.RomanticPartner = self
|
|
289
|
+
Rel = self.Relationships[Other.Id]
|
|
290
|
+
Rel.IsRomantic = True
|
|
291
|
+
Rel.Attraction = min(1.0, Rel.Attraction + random.uniform(0.3, 0.5))
|
|
292
|
+
Rel.UpdateType()
|
|
293
|
+
self.LifeLog.append(f"Year {Year}: Started a romantic relationship with {Other.Name}")
|
|
294
|
+
World.Log.append(f"{self.Name} and {Other.Name} started a romantic relationship ❤️")
|
|
295
|
+
|
|
296
|
+
# Maybe start a family
|
|
297
|
+
if (random.random() < 0.25 and self.Age < 40 and Other.Age < 40 and
|
|
298
|
+
self.Savings > 5000 and Other.Savings > 5000):
|
|
299
|
+
self._HaveChild(Other, Year, World)
|
|
300
|
+
|
|
301
|
+
def _HaveChild(self, Partner: "Person", Year: int, World: Any):
|
|
302
|
+
ChildName = f"Child_of_{self.Name}"
|
|
303
|
+
# Inherit blended traits
|
|
304
|
+
BlendedTraits = {}
|
|
305
|
+
for Trait in self.Traits:
|
|
306
|
+
MyVal = self.Traits.get(Trait, 0.5)
|
|
307
|
+
PartnerVal = Partner.Traits.get(Trait, 0.5)
|
|
308
|
+
# Weighted blend with small mutation
|
|
309
|
+
Inherited = (MyVal + PartnerVal) / 2 + random.gauss(0, 0.08)
|
|
310
|
+
BlendedTraits[Trait] = max(0.0, min(1.0, Inherited))
|
|
311
|
+
|
|
312
|
+
Child = Person(Name=ChildName, Age=0, Traits=BlendedTraits, Location=self.Location)
|
|
313
|
+
Child.YearBorn = Year
|
|
314
|
+
Child.Parents = [self, Partner]
|
|
315
|
+
self.Children.append(Child)
|
|
316
|
+
Partner.Children.append(Child)
|
|
317
|
+
World.AddPerson(Child)
|
|
318
|
+
self.LifeLog.append(f"Year {Year}: Had a child with {Partner.Name}")
|
|
319
|
+
World.Log.append(f"{self.Name} and {Partner.Name} welcomed a child 👶")
|
|
320
|
+
|
|
321
|
+
def _SeekPartner(self, Year: int, World: Any):
|
|
322
|
+
# Delegate to socialise — romance emerges from interactions
|
|
323
|
+
self._Socialize(Year, World)
|
|
324
|
+
|
|
325
|
+
def _OptimiseCareer(self, Year: int, World: Any):
|
|
326
|
+
if self.Job is None:
|
|
327
|
+
self._SeekJob(Year, World)
|
|
328
|
+
else:
|
|
329
|
+
# Look for better-paying job
|
|
330
|
+
BetterJobs = [J for J in World.Jobs if J.IsAvailable() and
|
|
331
|
+
J.Salary > self.Income * 1.15 and
|
|
332
|
+
J.SkillRequired <= self.SkillLevel + 0.1]
|
|
333
|
+
if BetterJobs and random.random() < 0.3 * self.Traits.get("RiskTolerance", 0.5):
|
|
334
|
+
self.Job.Vacate()
|
|
335
|
+
NewJob = max(BetterJobs, key=lambda J: J.Salary)
|
|
336
|
+
NewJob.Assign(self)
|
|
337
|
+
self.Job = NewJob
|
|
338
|
+
self.Income = NewJob.Salary
|
|
339
|
+
self.YearsAtCurrentJob = 0
|
|
340
|
+
World.Log.append(f"{self.Name} switched to a better-paying job as {NewJob.Role}")
|
|
341
|
+
|
|
342
|
+
def _ImproveSkills(self, Year: int, World: Any):
|
|
343
|
+
GainRate = self.Traits.get("Openness", 0.5) * 0.05 + self.Traits.get("Discipline", 0.5) * 0.05
|
|
344
|
+
self.SkillLevel = min(1.0, self.SkillLevel + GainRate + random.uniform(0, 0.03))
|
|
345
|
+
if not self.HasDegree and self.Age < 28 and random.random() < 0.2:
|
|
346
|
+
self.HasDegree = True
|
|
347
|
+
self.Education = min(1.0, self.Education + 0.3)
|
|
348
|
+
self.LifeLog.append(f"Year {Year}: Earned a university degree")
|
|
349
|
+
World.Log.append(f"{self.Name} earned a degree 🎓")
|
|
350
|
+
|
|
351
|
+
def _ConsiderMoving(self, Year: int, World: Any):
|
|
352
|
+
if not World.Locations or random.random() > self.Traits.get("RiskTolerance", 0.5) * 0.3:
|
|
353
|
+
return
|
|
354
|
+
BetterLocations = [L for L in World.Locations
|
|
355
|
+
if L != self.Location and L.OpportunityLevel > (self.Location.OpportunityLevel if self.Location else 0.5)]
|
|
356
|
+
if BetterLocations:
|
|
357
|
+
NewLoc = random.choice(BetterLocations)
|
|
358
|
+
if self.Location:
|
|
359
|
+
self.Location.RemoveResident(self)
|
|
360
|
+
self.Location = NewLoc
|
|
361
|
+
NewLoc.AddResident(self)
|
|
362
|
+
self.LifeLog.append(f"Year {Year}: Moved to {NewLoc.Name}")
|
|
363
|
+
World.Log.append(f"{self.Name} moved to {NewLoc.Name}")
|
|
364
|
+
|
|
365
|
+
# ------------------------------------------------------------------
|
|
366
|
+
# Goal updates
|
|
367
|
+
# ------------------------------------------------------------------
|
|
368
|
+
def _UpdateGoals(self, Year: int):
|
|
369
|
+
for Goal in self.Goals:
|
|
370
|
+
if Goal.Achieved:
|
|
371
|
+
continue
|
|
372
|
+
if Goal.GoalType == "GetJob":
|
|
373
|
+
Delta = 0.5 if self.Job else random.uniform(-0.02, 0.05)
|
|
374
|
+
elif Goal.GoalType == "GetPromotion":
|
|
375
|
+
Delta = 0.1 if (self.Job and self.YearsAtCurrentJob >= 2) else 0.0
|
|
376
|
+
elif Goal.GoalType == "MakeFriends":
|
|
377
|
+
FriendCount = self._CountRelationshipsByType(["friend", "close_friend"])
|
|
378
|
+
Delta = min(0.1, FriendCount * 0.05)
|
|
379
|
+
elif Goal.GoalType == "FindPartner":
|
|
380
|
+
Delta = 0.5 if self.RomanticPartner else 0.0
|
|
381
|
+
elif Goal.GoalType == "EarnMoney":
|
|
382
|
+
Delta = min(0.1, self.Income / 200000)
|
|
383
|
+
elif Goal.GoalType == "ImproveSkills":
|
|
384
|
+
Delta = self.SkillLevel * 0.1
|
|
385
|
+
elif Goal.GoalType == "StartFamily":
|
|
386
|
+
Delta = 0.5 if self.Children else 0.0
|
|
387
|
+
else:
|
|
388
|
+
Delta = random.uniform(0, 0.03)
|
|
389
|
+
|
|
390
|
+
Achieved = Goal.UpdateProgress(Delta)
|
|
391
|
+
if Achieved:
|
|
392
|
+
Goal.YearAchieved = Year
|
|
393
|
+
self.LifeLog.append(f"Year {Year}: Achieved goal — {Goal.GoalType}!")
|
|
394
|
+
self.LifeSatisfaction = min(1.0, self.LifeSatisfaction + 0.1)
|
|
395
|
+
# Create a new stretch goal
|
|
396
|
+
self._AddStretchGoal(Goal.GoalType)
|
|
397
|
+
|
|
398
|
+
def _AddStretchGoal(self, AchievedGoalType: str):
|
|
399
|
+
StretchMap = {
|
|
400
|
+
"GetJob": "GetPromotion",
|
|
401
|
+
"GetPromotion": "EarnMoney",
|
|
402
|
+
"MakeFriends": "SocializeMore",
|
|
403
|
+
"FindPartner": "StartFamily",
|
|
404
|
+
"ImproveSkills": "BuildBusiness",
|
|
405
|
+
"EarnMoney": "IncreaseStatus",
|
|
406
|
+
}
|
|
407
|
+
NextGoal = StretchMap.get(AchievedGoalType)
|
|
408
|
+
if NextGoal and not any(G.GoalType == NextGoal for G in self.Goals):
|
|
409
|
+
self.Goals.append(Goal(NextGoal, Priority=0.7))
|
|
410
|
+
|
|
411
|
+
# ------------------------------------------------------------------
|
|
412
|
+
# Mortality
|
|
413
|
+
# ------------------------------------------------------------------
|
|
414
|
+
def _CheckMortality(self, Year: int, World: Any):
|
|
415
|
+
if self.Age < 40:
|
|
416
|
+
BaseDeathChance = 0.002
|
|
417
|
+
elif self.Age < 60:
|
|
418
|
+
BaseDeathChance = 0.005
|
|
419
|
+
elif self.Age < 80:
|
|
420
|
+
BaseDeathChance = 0.02
|
|
421
|
+
else:
|
|
422
|
+
BaseDeathChance = 0.08
|
|
423
|
+
|
|
424
|
+
StressModifier = self.Stress * 0.005
|
|
425
|
+
if random.random() < BaseDeathChance + StressModifier:
|
|
426
|
+
self.IsAlive = False
|
|
427
|
+
self.LifeLog.append(f"Year {Year}: Died at age {self.Age}")
|
|
428
|
+
World.Log.append(f"💀 {self.Name} passed away at age {self.Age}")
|
|
429
|
+
if self.Job:
|
|
430
|
+
self.Job.Vacate()
|
|
431
|
+
self.Job = None
|
|
432
|
+
if self.RomanticPartner:
|
|
433
|
+
self.RomanticPartner.RomanticPartner = None
|
|
434
|
+
|
|
435
|
+
# ------------------------------------------------------------------
|
|
436
|
+
# Helpers
|
|
437
|
+
# ------------------------------------------------------------------
|
|
438
|
+
def _CountRelationshipsByType(self, Types: list) -> int:
|
|
439
|
+
return sum(1 for Rel in self.Relationships.values() if Rel.RelationshipType in Types)
|
|
440
|
+
|
|
441
|
+
def GetRelationship(self, Other: "Person") -> "Relationship | None":
|
|
442
|
+
return self.Relationships.get(Other.Id)
|
|
443
|
+
|
|
444
|
+
def Summary(self) -> dict:
|
|
445
|
+
return {
|
|
446
|
+
"Name": self.Name,
|
|
447
|
+
"Age": self.Age,
|
|
448
|
+
"Alive": self.IsAlive,
|
|
449
|
+
"Job": self.Job.Role if self.Job else "Unemployed",
|
|
450
|
+
"Income": round(self.Income, 2),
|
|
451
|
+
"Savings": round(self.Savings, 2),
|
|
452
|
+
"LifeSatisfaction": round(self.LifeSatisfaction, 3),
|
|
453
|
+
"Friends": self._CountRelationshipsByType(["friend", "close_friend"]),
|
|
454
|
+
"Partner": self.RomanticPartner.Name if self.RomanticPartner else None,
|
|
455
|
+
"Children": len(self.Children),
|
|
456
|
+
"GoalsAchieved": sum(1 for G in self.Goals if G.Achieved),
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
def __repr__(self):
|
|
460
|
+
Status = f"{'alive' if self.IsAlive else 'deceased'}, age={self.Age}"
|
|
461
|
+
Job = self.Job.Role if self.Job else "unemployed"
|
|
462
|
+
return f"Person({self.Name}, {Status}, job={Job}, happiness={self.LifeSatisfaction:.2f})"
|
|
463
|
+
|
|
464
|
+
def __str__(self):
|
|
465
|
+
Status = f"{'Alive' if self.IsAlive else 'Deceased'}, Age {self.Age}"
|
|
466
|
+
JobStr = f", Job: {self.Job.Role}" if self.Job else ", Unemployed"
|
|
467
|
+
PartnerStr = f", Partner: {self.RomanticPartner.Name}" if self.RomanticPartner else ""
|
|
468
|
+
return f"{self.Name} ({Status}{JobStr}{PartnerStr})"
|