|
| 1 | +import type { Colonist, ColonySystems, TurnEvent, SimulationState } from './state.js'; |
| 2 | +import { SeededRng } from './rng.js'; |
| 3 | + |
| 4 | +const MARS_RADIATION_MSV_PER_YEAR = 0.67 * 365; // ~244.55 mSv/year |
| 5 | + |
| 6 | +/** |
| 7 | + * Run all between-turn progression: aging, mortality, births, careers, |
| 8 | + * health degradation, resource production. All deterministic from seed. |
| 9 | + */ |
| 10 | +export function progressBetweenTurns( |
| 11 | + state: SimulationState, |
| 12 | + yearDelta: number, |
| 13 | + turnRng: SeededRng, |
| 14 | +): { state: SimulationState; events: TurnEvent[] } { |
| 15 | + const events: TurnEvent[] = []; |
| 16 | + const year = state.metadata.currentYear; |
| 17 | + const turn = state.metadata.currentTurn; |
| 18 | + let colonists = state.colonists.map(c => structuredClone(c)); |
| 19 | + let colony = structuredClone(state.colony); |
| 20 | + |
| 21 | + // 1. Age all colonists and accumulate radiation |
| 22 | + for (const c of colonists) { |
| 23 | + if (!c.health.alive) continue; |
| 24 | + c.career.yearsExperience += yearDelta; |
| 25 | + c.health.cumulativeRadiationMsv += MARS_RADIATION_MSV_PER_YEAR * yearDelta; |
| 26 | + |
| 27 | + // Bone density loss (stabilizes after ~20 years on Mars) |
| 28 | + const lossRate = c.core.marsborn ? 0.003 : 0.005; |
| 29 | + const yearsOnMars = year - (c.core.marsborn ? c.core.birthYear : 2035); |
| 30 | + const decayFactor = Math.max(0.5, 1 - lossRate * Math.min(yearsOnMars, 20)); |
| 31 | + c.health.boneDensityPct = Math.max(50, c.health.boneDensityPct * decayFactor); |
| 32 | + |
| 33 | + // Earth contacts decay |
| 34 | + if (c.social.earthContacts > 0 && turnRng.chance(0.15 * yearDelta)) { |
| 35 | + c.social.earthContacts = Math.max(0, c.social.earthContacts - 1); |
| 36 | + } |
| 37 | + } |
| 38 | + |
| 39 | + // 2. Natural mortality |
| 40 | + for (const c of colonists) { |
| 41 | + if (!c.health.alive) continue; |
| 42 | + const age = year - c.core.birthYear; |
| 43 | + if (age < 60) continue; |
| 44 | + |
| 45 | + let mortalityProb = 0; |
| 46 | + if (age >= 60) mortalityProb = 0.01 * yearDelta; |
| 47 | + if (age >= 70) mortalityProb = 0.03 * yearDelta; |
| 48 | + if (age >= 80) mortalityProb = 0.08 * yearDelta; |
| 49 | + if (age >= 90) mortalityProb = 0.20 * yearDelta; |
| 50 | + |
| 51 | + if (c.health.cumulativeRadiationMsv > 1000) mortalityProb += 0.02 * yearDelta; |
| 52 | + if (c.health.cumulativeRadiationMsv > 2000) mortalityProb += 0.05 * yearDelta; |
| 53 | + |
| 54 | + if (turnRng.chance(Math.min(mortalityProb, 0.5))) { |
| 55 | + c.health.alive = false; |
| 56 | + c.health.deathYear = year; |
| 57 | + c.health.deathCause = age >= 80 ? 'natural causes' : 'age-related complications'; |
| 58 | + c.narrative.lifeEvents.push({ year, event: `Died at age ${age} (${c.health.deathCause})`, source: 'kernel' }); |
| 59 | + events.push({ turn, year, type: 'death', description: `${c.core.name} died at age ${age}`, colonistId: c.core.id }); |
| 60 | + } |
| 61 | + } |
| 62 | + |
| 63 | + // 3. Births |
| 64 | + const aliveAdults = colonists.filter(c => c.health.alive && (year - c.core.birthYear) >= 20 && (year - c.core.birthYear) <= 42); |
| 65 | + const birthProb = colony.morale > 0.4 && colony.foodMonthsReserve > 6 ? 0.08 * yearDelta : 0.02 * yearDelta; |
| 66 | + const potentialParents = aliveAdults.filter(c => c.social.childrenIds.length < 3); |
| 67 | + |
| 68 | + for (let i = 0; i < potentialParents.length - 1; i += 2) { |
| 69 | + if (turnRng.chance(birthProb)) { |
| 70 | + const p1 = potentialParents[i]; |
| 71 | + const p2 = potentialParents[i + 1]; |
| 72 | + const childName = `${turnRng.pick(['Nova', 'Kai', 'Sol', 'Tera', 'Eos', 'Zan', 'Lyra', 'Orion', 'Vega', 'Juno', 'Atlas', 'Iris', 'Clio', 'Pax', 'Io', 'Thea'])} ${p1.core.name.split(' ').pop()}`; |
| 73 | + const childId = `col-mars-${year}-${turnRng.int(1000, 9999)}`; |
| 74 | + const child: Colonist = { |
| 75 | + core: { id: childId, name: childName, birthYear: year, marsborn: true, department: 'science', role: 'Child' }, |
| 76 | + health: { alive: true, boneDensityPct: 88, cumulativeRadiationMsv: 0, psychScore: 0.9, conditions: [] }, |
| 77 | + career: { specialization: 'Undetermined', yearsExperience: 0, rank: 'junior', achievements: ['Born on Mars'] }, |
| 78 | + social: { childrenIds: [], friendIds: [], earthContacts: 0 }, |
| 79 | + narrative: { lifeEvents: [{ year, event: `Born on Mars to ${p1.core.name} and ${p2.core.name}`, source: 'kernel' }], featured: false }, |
| 80 | + }; |
| 81 | + p1.social.childrenIds.push(childId); |
| 82 | + p2.social.childrenIds.push(childId); |
| 83 | + p1.narrative.lifeEvents.push({ year, event: `Child born: ${childName}`, source: 'kernel' }); |
| 84 | + p2.narrative.lifeEvents.push({ year, event: `Child born: ${childName}`, source: 'kernel' }); |
| 85 | + colonists.push(child); |
| 86 | + events.push({ turn, year, type: 'birth', description: `${childName} born to ${p1.core.name} and ${p2.core.name}`, colonistId: childId }); |
| 87 | + } |
| 88 | + } |
| 89 | + |
| 90 | + // 4. Career progression |
| 91 | + for (const c of colonists) { |
| 92 | + if (!c.health.alive) continue; |
| 93 | + const age = year - c.core.birthYear; |
| 94 | + |
| 95 | + // Mars-born children enter workforce at 18 |
| 96 | + if (c.core.role === 'Child' && age >= 18) { |
| 97 | + c.core.department = turnRng.pick(['medical', 'engineering', 'agriculture', 'science'] as const); |
| 98 | + c.career.specialization = turnRng.pick(['General', 'Support', 'Research']); |
| 99 | + c.core.role = `Junior ${c.career.specialization} Specialist`; |
| 100 | + c.career.rank = 'junior'; |
| 101 | + c.narrative.lifeEvents.push({ year, event: `Began career in ${c.core.department}`, source: 'kernel' }); |
| 102 | + } |
| 103 | + |
| 104 | + if (age < 18) continue; |
| 105 | + |
| 106 | + if (c.career.rank === 'junior' && c.career.yearsExperience >= 5 && turnRng.chance(0.15 * yearDelta)) { |
| 107 | + c.career.rank = 'senior'; |
| 108 | + c.narrative.lifeEvents.push({ year, event: `Promoted to Senior ${c.career.specialization}`, source: 'kernel' }); |
| 109 | + events.push({ turn, year, type: 'promotion', description: `${c.core.name} promoted to senior`, colonistId: c.core.id }); |
| 110 | + } |
| 111 | + if (c.career.rank === 'senior' && c.career.yearsExperience >= 12 && turnRng.chance(0.08 * yearDelta)) { |
| 112 | + c.career.rank = 'lead'; |
| 113 | + c.narrative.lifeEvents.push({ year, event: `Promoted to Lead ${c.career.specialization}`, source: 'kernel' }); |
| 114 | + events.push({ turn, year, type: 'promotion', description: `${c.core.name} promoted to lead`, colonistId: c.core.id }); |
| 115 | + } |
| 116 | + } |
| 117 | + |
| 118 | + // 5. Morale drift |
| 119 | + const foodPressure = colony.foodMonthsReserve < 6 ? -0.05 : 0; |
| 120 | + const popPressure = colonists.filter(c => c.health.alive).length > colony.lifeSupportCapacity ? -0.08 : 0; |
| 121 | + colony.morale = Math.max(0, Math.min(1, colony.morale + (0.6 - colony.morale) * 0.1 + foodPressure + popPressure)); |
| 122 | + |
| 123 | + // 6. Update population count |
| 124 | + colony.population = colonists.filter(c => c.health.alive).length; |
| 125 | + |
| 126 | + // 7. Resource production |
| 127 | + colony.foodMonthsReserve = Math.max(0, colony.foodMonthsReserve - (yearDelta * 0.5) + (colony.infrastructureModules * 0.3 * yearDelta)); |
| 128 | + colony.scienceOutput += yearDelta; |
| 129 | + |
| 130 | + return { |
| 131 | + state: { ...state, colonists, colony, eventLog: [...state.eventLog, ...events] }, |
| 132 | + events, |
| 133 | + }; |
| 134 | +} |
0 commit comments