Metadata-Version: 2.5
Name: feudal
Version: 0.0.3
Summary: Medieval manor economy, famine, trade and war simulation for research
Author: icecx
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: agent-based model,economics,famine,history,medieval,simulation,war
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Education
Classifier: Intended Audience :: Science/Research
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Scientific/Engineering
Classifier: Topic :: Sociology :: History
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: numpy>=1.24
Requires-Dist: pandas>=2.0
Requires-Dist: pyyaml>=6.0
Provides-Extra: dev
Requires-Dist: matplotlib>=3.7; extra == 'dev'
Requires-Dist: pytest>=7; extra == 'dev'
Provides-Extra: plot
Requires-Dist: matplotlib>=3.7; extra == 'plot'
Description-Content-Type: text/markdown

# feudal

**English summary.** `feudal` is a deterministic, vectorized simulation of medieval manors: harvests, granaries, rationing and famine, grain trade relayed across several manors, realms with famine relief, wars with plunder and conquest, loyalty and revolts, plague spreading along trade routes, famine migration, labour-limited farming with seed corn and land that is cleared or abandoned, market towns fed by the lords' rents, climate shifts and disasters, fictional faiths that spread, divide realms and feed the hungry through their churches, and rule-based realm policies ("AI rulers"). `fe.tell(sim)` writes a run up as a year-by-year chronicle (Korean or English). Optional matplotlib plots (`pip install feudal[plot]`). Same seed, same result. It ships with an ensemble runner that measures a policy's effect counterfactually: same world, same seed, same realm, only that realm's policy changed. No rendering; outputs are pandas DataFrames.

```bash
pip install feudal
```

```python
import feudal as fe

world = fe.World.generate(seed=1, n_manors=12)
df = fe.Sim(world, seed=1, events=[fe.Famine(year=5, severity=0.6)]).run(years=20)
print(fe.collapses(df))   # which manors collapse first, and when
```

Parameter values are rough orders of magnitude tuned for stable dynamics, **not historical calibration**. Treat results as properties of the model, not claims about the Middle Ages. The rest of this document is in Korean. Status: alpha (0.0.x); the API may change.

---

중세 영지(Manor) 단위의 인구·식량·교역 동역학을 돌리는 순수 로직 시뮬레이션 라이브러리. 렌더링은 없고, 재현성과 분석 편의성을 우선한다.

핵심 질문: **"기근이 나면 어느 영지부터 무너지는가?"**

> 파라미터 기본값은 동역학이 안정적으로 재현되도록 맞춘 대략적인 규모이고, 역사 고증이 아니다. 결과는 "이 모델의 성질"로 읽어야 하며 중세에 대한 주장이 아니다.

## 설치

```bash
pip install feudal          # 시뮬레이션
pip install feudal[plot]    # + 그래프 (matplotlib)
```

Python 3.11 이상(3.11·3.12·3.13에서 테스트), 의존성은 `numpy>=1.24`, `pandas>=2.0`, `pyyaml>=6.0`.

개발용 설치 (소스에서):

```bash
python -m venv .venv
# Windows: .venv\Scripts\activate   /   macOS·Linux: source .venv/bin/activate
pip install -e ".[dev]"
pytest
```

## 빠른 시작

관례적인 import는 `import feudal as fe`이고, 필요한 이름은 모두 `fe.`에서 바로 꺼낼 수 있다.

```python
import feudal as fe

world = fe.World.generate(seed=1, n_manors=12)
sim = fe.Sim(world, seed=1, events=[fe.Famine(year=5, severity=0.6)])
df = sim.run(years=20)      # = sim.manors(): 틱 × 영지 지표
print(fe.collapses(df))     # 붕괴 순서
```

### 반사실 실험: 교역이 기근을 완화하는가

```python
events = [fe.Famine(year=5, severity=0.7, affected=[0, 1, 2, 3, 4, 5])]  # 절반의 영지만 흉작
isolated = fe.Sim(world, seed=1, events=events, trade=False).run(years=12)
traded   = fe.Sim(world, seed=1, events=events, trade=True).run(years=12)

print(fe.losses(isolated).loc["total"], fe.losses(traded).loc["total"])
print(fe.collapses(isolated), fe.collapses(traded), sep="\n")
```

같은 `seed`라면 이벤트나 교역 플래그만 바꿔도 날씨 난수열은 그대로다. 따라서 두 실행은 개입한 지점 외에는 동일한 세계를 공유한다.

### 국가와 전쟁

`World.generate`는 영지를 국가(`Realm`) 몇 개로 나눈다. 기본값은 `realm.n_realms: 3`이고, `n_realms=` 인자로 바꿀 수 있다. 무작위로 뽑은 수도에서 교역 그래프 최단거리로 영토를 넓히므로, 각 국가는 그래프상 연결된 덩어리가 된다.

```python
events = [
    fe.Famine(year=5, severity=0.6),
    fe.War(year=5, attacker=0, defender=2, duration=None),  # 5년차(0부터 셈)에 선전포고, 승패가 날 때까지
]
sim = fe.Sim(world, seed=1, events=events)
df = sim.run(years=20)
print(fe.losses(df, by="realm"))  # 국가별 기아 사망·이주·전쟁 사망
rd = sim.realms()                 # 국가 단위 틱별 지표 (창고, 교전 강도, 세곡, 구휼, 군량, 약탈, 배상)
print(sim.wars())                 # 전쟁별 기록: 시작·종전 연도, 결과, 배상, 양측 약탈량, 병합한 영지

fe.Sim(world, seed=1, events=events, relief=False)   # 국가 구휼 끄기
```

### 민심과 반란, 전염병, 이주

```python
sim = fe.Sim(world, seed=1, events=[fe.Famine(3, 0.7, affected=[0, 1, 2]), fe.Plague(year=4)])
df = sim.run(10)
df[["loyalty", "in_revolt", "immigrants", "infected", "plague_deaths"]]  # 영지별 매주 값
sim.unrest()      # 반란 시작·종료, 이웃 나라로의 이탈 기록

fe.Plague(year=4, origin=7, initial_share=0.05)        # 발원지와 초기 감염 비율 지정
fe.params({"unrest": {"enabled": False}, "migration": {"enabled": False}})  # 기능 끄기
```

- **민심과 반란:** 영지마다 민심(0~1)이 있다. 매주 1 쪽으로 조금씩 회복하고, 다음 요인으로 깎인다.
  - 허용선(`tolerated_tax`, 22%)을 넘는 세율
  - 굶주림
  - 군량 징발
  - 접경 교전

  구휼을 받으면 오른다. 민심이 `revolt_below`(0.2) 아래면 반란을 일으켜 세금과 징발을 거부하고, `revolt_ends_above`(0.35) 위로 회복되면 반란이 끝난다. 반란 중에도 영주가 매긴 세율은 불만으로 계속 쌓인다. 반란이 `secession_weeks`(26주) 이어지면 영지는 자기 영주보다 세율이 낮은 이웃 나라 중 가장 낮은 곳으로 이탈한다(수도는 제외). 그런 이웃이 없으면 반란 상태로 남는다. 정복되거나 이탈한 영지는 민심 `conquered`(0.5)에서 다시 시작한다. 기본값에서 세율 35%만으로는 반란이 나지 않는다. 절반만 먹는 기근이면 약 20주, 전시 세율에 접경 피해가 겹치면 약 1년 만에 반란이 난다.
- **전염병 (`fe.Plague`):** 영지마다 감염자·면역자 비율을 두는 SIR 모델이다. 다음 경로로 이웃에 퍼진다.
  - 기본 왕래(`travel_contact`)
  - 교역을 켰을 때의 상인 왕래(`route_contact`)
  - 그 주에 실제로 오간 곡물 양(`trade_contact`)
  - 교전 중인 접경의 군대 이동(`war_contact`)

  감염자가 `min_cases`(1명) 미만이 되면 그 영지의 감염은 꺼진다. 우연한 소멸을 결정론적으로 근사한 것으로, 약한 접촉은 병을 옮기지 못하고 유행은 결국 끝난다. 기본값은 흑사병 규모다(감염된 영지는 인구의 약 절반을 잃는다).
- **이주 정착:** 기근 이주민은 그 주에 굶지 않은 이웃 영지로 가서 정착한다. 행선지는 재고가 많고 가까울수록, 같은 나라일수록(`border_factor`) 선호한다. 교전 중인 국경은 넘지 못한다. 떠난 사람 중 `settle_share`(70%)만 도착하고, 갈 곳이 없으면 모두 지도 밖으로 떠난다. 이주민은 출발지의 감염자·면역자 비율을 데려간다.
- 세 기능 모두 난수를 쓰지 않는다. 같은 시드면 결과가 같고, 반사실 실험도 그대로 된다.

### 농사: 노동력, 종자, 개간과 황폐

- **노동력:** 경작지를 모두 갈려면 ha당 `people_per_ha`(1.33)명이 필요하다. 노동 비율 `r = 인구 / (경작지 × people_per_ha)`라 하면 수확에 노동 계수가 곱해진다.
  - 일손이 모자랄 때(`r < 1`): `r^0.7`
  - 일손이 남을 때: `1 + 0.1 × (1 − 1/r)`. 최대 10%까지만 늘어나 수확 체감이 생긴다.

  역병이나 전쟁으로 사람이 줄면 수확도 줄고, 사람이 넘쳐도 그만큼 늘지 않는다. 기본 월드는 평균 `r ≈ 1`로 생성된다.
- **종자:** `base_yield`(600kg/ha)는 종자를 포함한 총수확이다.
  - 수확 때 이듬해 종자(`seed_per_ha`, 100kg/ha × 갈 수 있는 땅)를 먼저 떼어 비축한다. 종자 비축은 교역·징발·구휼에 쓰이지 않는다.
  - 파종기(4~8주)에 비축분을 뿌리고, 모자라면 4주치 식량을 넘는 재고에서 보탠다. 남은 종자는 다시 식량이 된다.
  - 배급이 절반 아래로 떨어질 지경이면 마을은 종자를 먹는다. 그러면 이듬해 수확은 뿌린 비율만큼만 난다(기근의 덫).
  - 세금은 종자분을 뺀 수확에만 매긴다.
- **개간과 황폐 (해마다):**
  - 붐비고(`r > 1.15`) 재고가 26주치를 넘는 영지는 ha당 곡물 300kg을 들여 경작지를 연 3%씩 넓힌다. 상한은 처음 경작지의 1.4배다.
  - 일손이 모자란(`r < 0.85`) 영지는 갈지 못한 땅의 30%가 해마다 황폐해진다. 하한은 처음의 30%다.
  - 교전 중인 접경 영지는 경작지의 5%를 잃는다.
- 지표: `arable_land`(경작지), `worked_share`(실제로 가는 비율), `seed_sown`, `seed_eaten`.

### 도시

```python
world = fe.World.generate(seed=1)                 # 기본으로 도시 1개 (world.n_cities)
world = fe.World.generate(seed=1, n_cities=0)     # 도시 없는 월드
[m for m in world.manors if m.is_city]
```

교역 간선이 가장 많은 영지가 도시가 된다(난수를 쓰지 않으므로 같은 시드의 다른 영지는 바뀌지 않는다). 도시는 인구 2배, 경작지 0.1배이고, 반경 25km 안의 모든 영지와 도로(교역 간선)로 이어진다. 도시를 먹여 살리는 것은 다음 셋이다.

- **영주의 소작료:** 세곡 중 국가 창고로 가지 않는 몫을 영주가 길로 이어진 가장 가까운 도시 시장에 판다. 운송 손실이 있고, 교전 중이면 막힌다. 도시와 이어지지 않은 영지에서는 예전처럼 영주 가계가 소비한다. 지표 `rent_grain`.
- **시장 교역:** 이웃 영지는 도시에는 비축 기준을 낮춰 판다(다음 수확까지 + `export_reserve_weeks` 8주, 다른 영지에는 + 52주). 농촌 이웃에 먼저 팔고 남는 여유분이 도시로 간다.
- **상경:** 일손이 남는 농촌 영지에서 길로 이어진 도시로 매년 0.4%가 올라간다. 도시가 굶지 않을 때만이고, 감염자도 함께 간다.

도시는 연 1.5%의 추가 사망률을 갖는다(`urban_deaths`, 이주 없이는 줄어드는 '도시의 무덤'). 역병은 도시 안에서 1.5배 빨리 퍼지고, 기근 이주민은 도시에 3배 끌린다.

### 기후와 재해

```python
events = [
    fe.ClimateShift(year=3, duration=10, factor=0.85, ramp_years=2),  # 10년 냉해기, 2년에 걸쳐 들어오고 빠짐
    fe.Flood(year=5, manors=[0, 3], severity=0.6),                     # 수확·저장 곡물·경작지·인명 피해
    fe.Locusts(year=7, center=4, radius_km=20, severity=0.7),          # 반경 안 모든 영지의 수확 피해
]
```

- **`ClimateShift`:** 날씨 계수에 배수를 곱한다. 매년 날씨를 뽑은 뒤 적용되고, 같은 해 `Famine`이 있으면 기근 값이 우선한다.
- **`Flood`:** 심각도 s일 때 피해는 다음과 같다.
  - 그해 수확: `s`만큼 감소
  - 저장 곡물: `s × stock_share`(0.5) 소실
  - 경작지: `s × land_share`(0.2) 유실. 나중에 다시 개간할 수 있다.
  - 인명: `s × death_share`(0.01) 사망. 지표 `disaster_deaths`
- **`Locusts`:** 중심 영지에서 반경 안 모든 영지의 그해 수확이 `severity`만큼 준다.
- 재해 이벤트는 `ctx.stock_loss`, `ctx.land_loss`, `ctx.people_loss`, `ctx.loyalty_loss`에 피해 비율을, `ctx.winter`에 겨울의 혹독함을 적어 직접 만들 수도 있다.

### 약탈자, 가축 역병, 혹한, 농민 봉기

```python
events = [
    fe.Raiders(year=4, count=3, severity=0.8),                 # 지도 변두리 3곳 습격 (manors=[...]로 지정 가능)
    fe.Murrain(year=6, duration=4, severity=0.4),              # 쟁기 끌 소가 죽어 수확 40% 감소, 4년에 걸쳐 회복
    fe.HarshWinter(year=8, severity=1.0),                      # 그해 겨울(39~51주)이 혹독함
    fe.PeasantUprising(year=9, origin=5, reach=2, shock=0.6),  # 영지 5에서 봉기, 길을 따라 2단계까지 번짐
]
```

| 이벤트 | 효과 (심각도 s) |
|---|---|
| `Raiders` | 바깥 세력의 습격. 대상은 지정한 영지, 또는 지도 중심에서 가장 먼 `count`곳이다. 피해는 저장 곡물 `s × 0.6` 약탈(어느 나라에도 가지 않고 사라짐), 그해 수확 `s × 0.3` 소실, 인명 `s × 0.03`, 민심 `s × 0.3` 하락이다. |
| `Murrain` | 가축 역병. 첫해 수확이 `s`만큼 줄고 `duration`년에 걸쳐 선형으로 회복한다. 사람은 직접 죽지 않지만, 여러 해 이어지는 흉작이라 가장 치명적인 축에 든다. |
| `HarshWinter` | 그해 겨울 13주 동안 식량 필요량이 `s × 30%` 늘고, 저장 곡물이 주당 `s × 1%` 더 상하며, 주당 `s × 0.2%`가 추위로 죽는다(`disaster_deaths`). 값은 params.yaml의 `winter`에서 바꾼다. |
| `PeasantUprising` | 발원 영지의 민심이 `shock`만큼, 길로 h단계 떨어진 영지는 `shock × spread^h`만큼 떨어진다(`reach`단계까지). 민심이 반란 기준 아래로 떨어진 곳은 평소처럼 반란·이탈한다. 민심이 높은 나라에서는 지나가는 소동이지만, 세금이 무거워 민심이 이미 낮은 나라에서는 연쇄 반란으로 번진다. |

### 종교

영지마다 신앙별 신자 비율이 있다. 신앙은 가상의 것(신앙 A, B, ...)이고 실제 종교의 모형이 아니다. 월드를 만들 때 `n_faiths`개(기본 2) 신앙이 무작위 발상지에서 길을 따라 퍼져 있고, 나라의 통치자는 수도의 신앙을 따른다. 국경과 신앙의 경계는 대개 어긋난다.

```python
world = fe.World.generate(seed=3, n_faiths=2)
events = [
    fe.Schism(year=4, realm=0, share=0.6),                         # 나라 0의 통치자가 새 신앙을 세우고 신자 60%가 따름
    fe.Missionaries(year=6, faith=0, manors=[0, 2, 3], strength=0.5),  # 신앙 A 선교: 다른 신앙 신자의 절반을 개종
    fe.Crusade(year=8, faith=0, duration=2),                       # 신앙 A 나라들이 맞닿은 이교 나라에 선전포고
]
sim = fe.Sim(world, seed=3, events=events, policies={0: fe.Aggressive(), 1: fe.Cautious(), 2: fe.Steward()})
sim.run(20)
sim.faiths()        # 주 × 신앙: 신자 수, 교회 창고
sim.conversions()   # 영지의 다수 신앙이 바뀜(converted), 통치자 개종, 분열, 선교 기록
```

- **퍼짐:** 매주 평화로운 이웃과 섞이고(`mixing`, 큰 이웃일수록 영향이 크다), 통치자의 신앙으로 기운다(`conversion`). 기근과 역병이 돌면 둘 다 빨라진다(`crisis_boost`). 이주민은 신앙을 들고 간다.
- **민심과 이탈:** 통치자와 신앙이 다른 사람의 비율만큼 민심이 매주 깎인다(`loyalty_weight`). 반란 영지는 `세율 − faith_affinity × 그 나라 신앙의 신자 비율`이 지금보다 낮은 이웃 나라로만 넘어간다. 그래서 세금이 조금 무거워도 같은 신앙의 나라로 가고, 같은 두 나라 사이를 오가지 않는다.
- **정복:** 이교 영지를 병합하면 민심이 `conquest_penalty × (1 − 정복자 신앙 비율)`만큼 더 낮게 시작한다. 정복한 이교 영지는 반란 끝에 같은 신앙의 옛 나라로 돌아가기 쉽다.
- **교역:** 신앙이 다른 영지 사이의 교역에는 `trade_tariff × (1 − 두 영지에서 고른 두 사람이 같은 신앙일 확률)`의 비용이 더 든다(해마다 연초에 계산).
- **교회:** 각 신앙의 교회가 신자들의 수확(영지 몫)에서 `tithe`(기본 5%)를 받는다. 굶는 신자에게 먼저 구휼하고, 창고가 신자 필요량의 `reserve_weeks`(기본 1주)를 넘으면 넘는 만큼을 신자 수 비율로 나눠 준다. 결과적으로 부자 영지의 수확 일부가 가난한 영지로 간다. 지표 `tithe`, `alms`.
- **정책:** 관측에 `faith`, `conformity`(통치자와 같은 신앙인 백성의 비율), `faith_shares`(신앙별 백성 비율)가 있고, 이웃 정보에도 `faith`가 있다. 행동 `fe.Convert(faith)`로 통치자가 개종하면, 영지 민심이 `convert_shock × 옛 신앙 비율`만큼 떨어진다. `Cautious`는 이교 이웃이면 인구 비 기준을 `zeal`(기본 1.2)로 나눈 값, 즉 1.5배에도 싸우고, `Aggressive`는 이교 이웃을 먼저 친다. `Steward`는 백성의 60% 넘게 다른 신앙을 믿으면 그 신앙으로 개종한다(`convert_above`).
- `religion.enabled: false`면 모두 한 신앙이고 교회도 없다. 결과는 종교를 넣기 전과 비트 단위로 같다(확인함).

### 그래프 (`feudal.plot`)

```python
from feudal import plot

plot.plot_series(sim.manors(), "population", by="realm")        # 국가별 인구 추이 ("manor", None도 가능)
plot.plot_map(sim, color_by="infected", tick=6 * 52)             # 지도: 크기=인구, 네모=도시, 색=국가 또는 지표
plot.plot_timeline(sim)                                          # 전쟁 기간과 반란·이탈
plot.dashboard(sim).savefig("run.png")                           # 위 넷을 한 장에
```

모든 함수는 `ax=`를 받아 기존 그림에 그릴 수 있고, axes를 돌려준다. matplotlib이 없으면 설치 안내와 함께 `ImportError`를 낸다.

### 연대기 (`fe.chronicle`, `fe.tell`)

실행이 끝난 시뮬레이션을 연도별 사건 기록으로 바꾼다. 이벤트, 선전포고와 종전(병합·배상 포함), 강화, 반란과 이탈, 붕괴, 정책의 세율·구휼 변경, 그리고 국가 인구의 일정 비율을 넘는 기아·역병 사망이 들어간다.

```python
sim = fe.Sim(world, seed=3, events=[fe.Famine(year=2, severity=0.6), fe.War(year=4, attacker=0, defender=1)])
sim.run(8)
print(fe.tell(sim))                    # [2년] / - 기근: 모든 영지의 수확이 60% 줄었다 / ...
log = fe.chronicle(sim)                # 표: year, week, kind, realm_id, manor_id, text
names = {0: "웨식스", 1: "머시아"}
print(fe.tell(sim, names=names, losses_above=0.1))
print(fe.tell(sim, lang="en"))
```

- `names`로 나라에 이름을 붙인다(`{나라 id: 이름}`). 한국어 조사(이/가, 을/를, 과/와)는 이름 끝 글자에 맞춰 고른다.
- `losses_above`: 한 해 기아·역병 사망이 그해 첫 주 국가 인구의 이 비율을 넘을 때만 적는다(기본 5%).
- `lang="en"`이면 영어로 쓴다.

### 국가 정책 (AI 인터페이스)

국가마다 정책을 붙이면, 정책이 매년 초(지난해 전쟁 판정 직후, 이벤트 직전) 관측을 보고 행동을 고른다.

```python
from dataclasses import dataclass

@dataclass
class Hungry:
    """예시: 창고가 12주치를 넘으면 가장 약해 보이는 이웃을 친다."""
    name: str = "hungry"

    def decide(self, obs, rng):
        if obs.at_war_with or obs.granary_weeks < 12:
            return []
        targets = [n for n in obs.neighbors if not n.truce]
        if not targets:
            return []
        weakest = min(targets, key=lambda n: n.population)
        return [fe.DeclareWar(weakest.realm_id, reason=f"granary {obs.granary_weeks:.0f}w")]

sim = fe.Sim(world, seed=1, policies={0: Hungry(), 1: fe.Passive(), 2: fe.Scripted({3: [fe.SetTaxRate(0.3)]})})
sim.run(years=20)
sim.decisions()   # 연도, 국가, 정책, 행동, 대상, 값, 수락 여부, 거부 사유, 정책이 밝힌 이유
```

- **관측 (`fe.Observation`):** 자기 나라의 인구, 창고, 영지 재고 합, 주간 필요량, 세율, 구휼 여부를 본다. 지난해 기록(식량 충족률, 기아 사망, 전쟁 사망, 역병 사망, 군량 충족률), 인구 가중 평균 민심(`loyalty`)과 반란 중인 영지 수(`manors_in_revolt`), 교전 상대, 끝난 전쟁 이력(`past_wars`: 상대, 공격 여부, 기간, 승·패·무), 신앙(`faith`, `conformity`, `faith_shares`)도 포함된다. 이웃(교역 간선으로 맞닿은 나라)에 대해서는 인구·창고 추정치, 교전 여부, 휴전 여부, 접경 영지 수, 신앙을 본다. 이웃 추정치에는 `policy.observation_noise`만큼의 상대 오차가 섞인다.
- **행동:**
  - `DeclareWar(target, intensity)`: 승패가 날 때까지 계속되는 전쟁을 선포한다.
  - `MakePeace(target)`: 강화를 청한다. 전쟁이 즉시 끝나고, 청한 쪽이 창고의 `war.peace_tribute_share`를 상대에게 공물로 넘긴다. 결정 없이 빠져나오는 길을 막기 위한 대가다.
  - `SetTaxRate(rate)`: 소속 영지 전체의 세율을 바꾼다. `min_tax_rate`~`max_tax_rate`로 제한된다.
  - `SetRelief(enabled)`: 이 나라의 구휼을 켜거나 끈다.
  - `Convert(faith)`: 통치자가 다른 신앙으로 개종한다(위 '종교').
  - 행동마다 `reason=`으로 이유를 남길 수 있다.
- **거부되는 행동:** 자기 자신·없는 나라에 대한 선전포고, 이미 교전 중인 상대에 대한 선전포고, 휴전 중(`war.truce_years`) 선전포고, 교전하지 않는 상대와의 강화. 거부된 행동도 결정 로그에 사유와 함께 남는다. 휴전은 정책에만 적용되고, 스크립트 `War` 이벤트에는 적용되지 않는다.
- **동시 결정:** 모든 나라의 관측을 먼저 만든 뒤 국가 순서대로 행동을 적용한다. 같은 해에는 누구도 다른 나라의 그해 결정을 보지 못한다.
- **재현성:** 각 나라의 정책은 날씨와 분리된 자기 전용 난수 스트림(`rng`)을 받는다. 따라서 정책을 바꾸거나 추가해도 날씨와 다른 나라의 난수는 그대로다. `Passive`만 붙인 실행은 정책이 없는 실행과 결과가 완전히 같다.
- **정책은 내부 상태를 두지 않는다.** 스냅샷에 담기지 않기 때문이다. 필요한 정보는 관측에서 얻는다.

#### 내장 규칙 기반 정책 (`fe.Cautious`, `fe.Aggressive`, `fe.Steward`)

| 정책 | 전쟁 | 강화 | 세금·구휼 |
|---|---|---|---|
| `Cautious` | 창고가 12주치 이상이고, 굶주린 해가 아니고, 민심이 0.6 이상이며 반란이 없고, 마지막 전쟁 후 10년이 지났을 때만 싸운다. 대상은 인구 비(내 인구 ÷ 상대 추정 인구)가 1.8배 이상인 이웃 중 가장 약한 쪽이며, 20년 안에 진 상대는 피한다. | 군량 충족률 80% 미만, 창고 2주치 미만, 기아 사망이 인구 1% 초과 중 하나라도 해당하면 강화를 청한다. | 건드리지 않는다. |
| `Aggressive` | 창고가 6주치 이상이면 인구 비 0.9 이상인 이웃 중 창고가 가장 많아 보이는 쪽을 친다. 한 번에 한 전쟁만 한다. | 군량 충족률이 50% 아래로 떨어질 때만 청한다. | 교전 중이면 세율 35%, 평시 22%. 선포가 거부될 수 있으므로 전시 세율은 전쟁 이듬해부터 적용한다. |
| `Steward` | 먼저 싸우지 않는다. | 공격받으면 이듬해 바로 공물을 내고 강화를 산다. | 굶주린 해 다음이나 민심이 0.5 아래이거나 반란이 있으면 세율 10%, 그 밖에는 22%. 구휼은 항상 켠다. |

모든 기준값은 생성자 인자다(예: `fe.Cautious(attack_odds=1.5, rest_years=5)`).

시드 8개 × 30년 평균, 세 나라 모두 같은 정책 (월드 전체 합계):

| 정책 | 전쟁 수 | 기아 사망 | 전쟁 사망 | 반란 (영지·주) | 최종 경작지 (ha) | 최종 인구 |
|---|---|---|---|---|---|---|
| `Passive` | 0 | 400 | 0 | 95 | 3,118 | 4,896 |
| `Steward` | 0 | 320 | 0 | 1 | 3,169 | 5,008 |
| `Cautious` | 2.8 | 388 | 452 | 194 | 2,967 | 4,452 |
| `Aggressive` | 12.0 | 399 | 1,758 | 414 | 2,329 | 3,101 |

공격형 세계는 전쟁 사망과 반란이 가장 많고, 접경의 땅이 버려져 경작지가 25% 줄며, 최종 인구가 `Passive`보다 37% 적다. 배급이 있어 기아 사망은 다른 정책과 비슷하다. 사람들은 굶어 죽기보다 전쟁으로 죽고, 일손과 땅을 잃어 덜 늘어난다. 평화로운 세계에서도 반란이 있는 것은 주로 신앙이 다른 영지 때문이다. 정책 하나의 효과를 국가 크기와 분리해서 보려면 아래 앙상블 실험을 쓴다.

### 앙상블 실험

정책 배치(`Lineup`) 여러 개를 시드 여러 개로 돌리고, 국가별 요약을 표 하나로 모은다. 같은 시드에서는 모든 배치가 같은 세계와 같은 날씨를 공유한다.

```python
import feudal as fe

if __name__ == "__main__":          # n_jobs > 1 을 Windows에서 쓸 때 필요
    lineups = [fe.uniform("all_passive", fe.Passive)]
    lineups += fe.focal_lineups("aggressive", fe.Aggressive, fe.Passive, realm_ids=[0, 1, 2])
    df = fe.run_ensemble(lineups, seeds=range(20), years=30, n_jobs=8)

    own = fe.focal_effect(df, baseline="all_passive")                 # 초점 국가 자신
    others = fe.focal_effect(df, baseline="all_passive", on="others")  # 나머지 국가 합계 (파급 효과)
    print(fe.summarize(own, "lineup", ["population_change", "final_granary"]))
    print(fe.win_rate(own, "population_change"))
```

- `focal_lineups(name, focal, rest, realm_ids)`: 초점 정책을 국가마다 하나씩 돌려 가며 배치하고, 나머지는 `rest`로 채운다.
- `focal_effect`: 같은 시드, 같은 국가에서 "그 나라 정책만 바꾼 실행"과 기준 배치의 차이다. 국가의 크기·위치·운이 상쇄되므로 정책 자체의 효과만 남는다.
- `run_ensemble` 결과의 국가별 요약 컬럼: `faith`, `final_conformity`, `initial_population`, `final_population`, `population_change`, `final_granary`, `manors_start`, `manors_end`, `final_arable_land`, `famine_deaths`, `war_deaths`, `plague_deaths`, `emigrants`, `immigrants`, `revolt_weeks`(반란 중이던 영지·주 수), `wars_started`, `wars_defended`, `wars_won`, `wars_lost`, `wars_drawn`, `plunder`, `reparations_net`. 단일 실행의 요약은 `fe.realm_summary(sim)`으로 얻는다.
- `n_jobs > 1`이면 프로세스 병렬로 돌리고, 결과는 직렬 실행과 완전히 같다. 이때 정책 팩토리는 클래스나 모듈 수준 함수여야 하고 람다는 쓸 수 없다.

#### 실험 결과

재현 스크립트는 소스 배포본(sdist)의 `experiments/` 폴더에 있고, 실행하면 결과 CSV가 `experiments/results/`에 저장된다. 아래 수치는 모두 시드 20개 × 국가 3곳 × 30년이고, "같은 나라가 배경 정책을 썼을 때"와의 차이(평균 ± 표준오차)다. 인구 변화에는 정복으로 얻거나 잃은 영지의 인구가 포함된다.

**어떤 정책이 유리한가는 이웃에 달려 있다** (`experiments/policy_backgrounds.py`)

| 배경 | 초점 정책 | 자기 인구 | 자기 창고 | 영지 수 | 이웃 인구 합 | 인구가 늘어난 비율 |
|---|---|---|---|---|---|---|
| 모두 `Passive` | `Aggressive` | −414 ± 89 | −21,022 ± 4,889 | −0.2 | −172 | 30% |
| 모두 `Passive` | `Cautious` | −33 ± 26 | +3,247 ± 2,354 | +0.2 | −147 | 20% |
| 모두 `Passive` | `Steward` | +78 ± 18 | −6,361 ± 2,342 | +0.2 | −43 | 90% |
| 모두 `Aggressive` | `Passive` | +326 ± 138 | +18,670 ± 8,391 | +0.2 | +129 | 60% |
| 모두 `Aggressive` | `Cautious` | +419 ± 139 | +24,057 ± 8,278 | +0.6 | −17 | 70% |
| 모두 `Aggressive` | `Steward` | +576 ± 120 | +19,337 ± 6,604 | +0.6 | +127 | 80% |

- **평화로운 이웃 사이에서 공격형은 모든 면에서 손해다.** 인구(−414), 창고(−21,022), 영지(−0.2)를 모두 잃고, 이웃도 잃는다(−172). 전시 세율과 접경 피해로 반란을 일으켜 적국으로 넘어가는 영지가 정복으로 얻는 영지보다 많고, 버려진 접경의 땅과 줄어든 일손이 수확을 깎는다. 신중형은 인구를 조금 잃고 영지를 조금 얻는다. 창고를 풀어 구휼하고 굶주린 해에 세금을 낮추는 `Steward`는 창고를 내주고 인구를 얻는다(10번 중 9번).
- **공격적인 이웃 사이에서는 싸우지 않는 쪽이 인구·창고·영지를 모두 얻는다.** 이웃 공격형 나라끼리 싸우는 동안 그 접경 영지가 반란 끝에 세율 낮은 나라로 넘어오기 때문이다. 종교가 들어간 뒤로는 공격형 이웃 사이에서 `Steward`가 인구를 가장 많이 얻는다(+576).
- 결론은 모델을 넓힐 때마다 바뀌었다. 전쟁과 정복만 있을 때 공격형은 "인구를 잃고 창고·영지를 얻는" 트레이드오프였다. 민심·반란을 넣자 창고 이득이 사라졌고, 노동력·토지를 넣자 창고까지 잃게 됐다.

**공격성의 최적점** (`experiments/aggression_sweep.py`): 배경은 `Passive`. `Aggressive`가 공격에 필요로 하는 인구 비(`attack_odds`)를 바꿨다.

| `attack_odds` | 자기 인구 | 자기 창고 | 영지 수 | 선전포고 수 | 패전 수 | 이웃 인구 합 |
|---|---|---|---|---|---|---|
| 0.8 | −423 ± 89 | −23,064 | −0.20 | 3.8 | 0.47 | −185 |
| 1.0 | −423 ± 91 | −21,843 | −0.27 | 3.4 | 0.37 | −115 |
| 1.2 | −334 ± 87 | −17,519 | −0.02 | 3.4 | 0.27 | −181 |
| 1.5 | −230 ± 81 | −8,094 | +0.22 | 3.3 | 0.13 | −252 |
| 1.8 | −158 ± 74 | −4,460 | +0.30 | 3.1 | 0.08 | −266 |
| 2.2 | −53 ± 59 | −2,926 | +0.48 | 2.7 | 0.03 | −293 |
| 2.5 | −33 ± 52 | **−1,942** | **+0.48** | 2.5 | 0.02 | −271 |

- 공격 기준이 낮을수록(무모할수록) 모든 면에서 손해가 커진다. 1.5 이상이면 영지는 얻지만, 창고는 어느 기준에서도 손해이고(2.5에서도 −1,942) 자기 인구도 늘 줄어든다. 모델을 넓힐 때마다 최적점이 신중한 쪽으로 옮겨 갔다. 전쟁·정복만 있을 때는 창고 최적점이 1.2였고, 반란을 넣은 뒤에는 1.8~2.2였다. 배급과 중계 교역을 넣은 뒤로는 창고가 흑자가 되는 기준이 없다.
- 무모한 공격자의 이웃은 인명 피해가 거의 없다. 공격자가 지는 전쟁을 하고, 그 접경 영지가 이웃으로 넘어오기 때문이다. 이웃의 피해가 가장 큰 것은 확실히 이기는 전쟁만 고르는 신중한 공격자다.

**교역은 기근을 막고 역병을 퍼뜨린다** (`experiments/trade_plague_migration.py`): 시드 20개 × 10년. 표의 값은 월드 전체 총손실(기아·역병 사망 + 지도 밖으로 영영 떠난 이주민)의 평균이다. 기근은 3년차에 영지 절반의 수확 70% 감소, 역병은 3년차(기근 뒤 역병은 4년차) 발생.

| 충격 | 교역+이주 | 교역만 | 이주만 | 둘 다 없음 |
|---|---|---|---|---|
| 기근 | **596** | 671 | 749 | 871 |
| 역병 | 2,563 | 2,577 | 2,167 | 2,144 |
| 기근 후 역병 | 2,710 | 2,750 | **2,279** | 2,309 |

- 기근만 있으면 교역과 이주 둘 다 손실을 줄이고, 함께일 때 가장 적다. 배급과 중계 교역이 들어가면서 기근 손실이 0.0.2의 절반쯤으로 줄었다(1,222 → 596).
- 역병이 돌면 교역이 손실을 약 20% 키운다(감염 영지 12곳 중 8.1곳 → 11.8곳). 도시가 없던 모델에서는 약 3배였는데, 도시로 모이는 도로가 생기면서 교역이 없어도 역병이 꽤 멀리 퍼지게 됐다.
- 기근 다음 해에 역병이 오면, 기근 때 사람을 살린 교역의 이득보다 역병을 퍼뜨린 손해가 더 크다.
- 이주가 역병을 퍼뜨리는 효과는 작다(교역이 없을 때 역병 사망 1,770 → 1,865).

**노동력과 토지가 흑사병과 냉해기를 어떻게 바꾸나** (`experiments/plague_climate_land.py`): 시드 20개 × 25년, 월드 전체. 3년차에 흑사병, 또는 3년차부터 10년 냉해기(날씨 × 0.85). "고정"은 노동력과 개간·황폐를 끈 모델이다(종자는 켜 둠).

| 충격 | 모델 | 인구 최저 | 최종 인구 | 최종 경작지 (ha) | 충격 이듬해 1인당 곡물 (kg) | 기근 사망 |
|---|---|---|---|---|---|---|
| 없음 | 노동·토지 | 4,729 | 4,835 | 3,082 | 168 | 381 |
| 없음 | 고정 | 4,785 | 4,929 | 3,180 | 175 | 311 |
| 흑사병 | 노동·토지 | 2,464 | 2,569 | **1,857** | **282** | 177 |
| 흑사병 | 고정 | 2,478 | 2,639 | 3,180 | 552 | 126 |
| 냉해 10년 | 노동·토지 | **4,330** | **4,421** | 2,901 | 138 | **683** |
| 냉해 10년 | 고정 | 4,489 | 4,644 | 3,180 | 145 | 520 |

- **흑사병 뒤 살아남은 사람의 '횡재'가 절반으로 준다.** 땅에만 비례하는 모델에서는 인구가 절반이 되면 1인당 곡물이 세 배 넘게 된다(552kg). 노동력 모델에서는 일손이 모자라 땅을 다 갈지 못해 282kg에 그친다. 경작지의 40%가 황폐해진다.
- **냉해기는 노동력·토지 모델에서 더 오래 아프다.** 굶주림으로 일손이 줄고 땅이 버려지면, 날씨가 돌아온 뒤에도 수확이 바로 회복되지 않는다. 그래서 기근 사망이 31% 많고, 최종 인구는 223명 적다.

**종교가 바꾸는 것** (`experiments/religion.py`): 시드 20개 × 30년, 세 나라가 같은 정책. 월드 전체 값이다. "신앙"은 교회를 끈 것이다.

| 정책 | 설정 | 기아 사망 | 전쟁 사망 | 반란 (영지·주) | 이탈 | 전쟁 수 | 최종 인구 | 통치자와 같은 신앙 |
|---|---|---|---|---|---|---|---|---|
| `Passive` | 종교 없음 | 436 | 0 | 39 | 0.4 | 0 | 4,829 | 100% |
| `Passive` | 신앙 | 430 | 0 | 83 | 0.7 | 0 | 4,837 | 86% |
| `Passive` | 신앙+교회 | 446 | 0 | 89 | 0.7 | 0 | 4,814 | 86% |
| `Cautious` | 종교 없음 | 444 | 401 | 86 | 0.6 | 3.2 | 4,415 | 100% |
| `Cautious` | 신앙 | 437 | 405 | 162 | 0.8 | 3.4 | 4,420 | 87% |
| `Cautious` | 신앙+교회 | 450 | 432 | 171 | 0.7 | 3.3 | 4,372 | 87% |
| `Aggressive` | 종교 없음 | 470 | 1,519 | 528 | 2.5 | 11.8 | 3,226 | 100% |
| `Aggressive` | 신앙+교회 | 473 | 1,526 | 491 | 2.7 | 11.8 | 3,213 | 88% |

- **신앙이 다른 영지는 반란을 두 배로 늘린다.** 평화로운 세계에서도 반란이 약 2배가 된다. 다만 이교 영지만으로는 반란 기준까지 잘 내려가지 않아, 세금·굶주림·전쟁이 겹친 곳에서 일어난다. 공격형 세계는 원래 반란이 많아 차이가 작다.
- **성전은 전쟁을 조금만 늘린다.** `Cautious`가 이교 이웃에 더 쉽게 싸워도 전쟁은 3.2 → 3.4회다. 신앙 경계와 국경이 맞닿는 곳이 많지 않기 때문이다.
- **교회 창고는 기근을 막지 못한다.** 교회가 1주 넘게 쌓아 둔 곡물은 나눠 주지만, 쌓아 둔 1주치가 상하는 만큼 기아 사망이 오히려 조금 는다(430 → 446). 비축을 2주로 늘리면 기근이 든 해가 있어도 사망이 더 많았다(시드 8개, 20년: 596 → 625). 마을 재고와 국가 창고가 이미 비축 역할을 하므로 세 번째 창고는 상할 뿐이다.
- 30년 동안 통치자의 신앙으로 개종하는 속도와 이웃과 섞이는 속도가 맞서서, 백성의 약 13%가 통치자와 다른 신앙으로 남는다.

### 저장과 복원

```python
world.to_json("world.json"); world = fe.World.from_json("world.json")

snap = sim.snapshot()        # JSON 직렬화 가능한 dict (틱, 영지 상태, 난수 상태, 지금까지의 지표)
sim.restore(snap)            # 같은 world·events·policies로 만든 Sim에 복원
sim.current_world()          # 현재 상태가 반영된 World
```

### 파라미터

모든 수치는 패키지에 포함된 `feudal/data/params.yaml`에 있고(`fe.params()`가 이 파일을 읽는다), 각 값의 단위와 근거는 파일 안 주석으로 적혀 있다. 기본값 대신 다른 파일을 쓰거나 일부만 바꿀 수 있다. `fe.Sim`은 시작할 때 파라미터를 검사하고, 시뮬레이션을 조용히 망가뜨릴 값(비율이 0~1 밖, `max_loss ≥ 1`, `decisive_ratio < 1` 등)이 있으면 `ValueError`를 낸다. 이벤트(`Famine`의 `severity`가 0~1 밖, `War`의 강도가 0 이하)와 정책 행동(NaN 세율, 0 이하 강도의 선전포고)도 같은 식으로 거부된다.

```python
p = fe.params()                                    # 기본값
p = fe.params({"trade": {"reserve_weeks": 26}})    # 기본값에서 일부만 바꿈
p = fe.params(path="my_params.yaml")               # 파일: 바꿀 값만 적어도 되고, 없는 항목은 기본값
sim = fe.Sim(world, p, seed=1)
```

## 모델 요약

| 항목 | 내용 |
|---|---|
| 시간 | 1틱 = 1주, 1년 = 52틱. 0주차 = 봄 시작. 파종 4–8주, 수확 26–33주(가을 초중반). |
| 생산 | 수확 8주 동안 `경작지 × 비옥도 × base_yield × 날씨 × 노동 계수 × 파종 비율`을 나눠 들인다. 이듬해 종자를 먼저 떼고, 나머지에 영주 세율(`lord_tax_rate`)을 매긴다. 세곡의 `granary_share`는 국가 창고로, 나머지는 길로 이어진 도시 시장으로 간다. |
| 소비 | 매주 `인구 × grain_per_person_week`. 재고는 매주 `spoilage_per_week`만큼 손실. |
| 배급 | 수확기가 아닐 때 재고가 다음 수확까지 온전히 먹기에 모자라면 `재고 / (주간 필요량 × 남은 주)`만큼만 먹는다. 단, `rationing.min_ration` 아래로는 줄이지 않는다. |
| 인구 | 식량 충족률 `s = 먹은 양 / 필요량`. `s = 1`이면 연 0.5% 증가, `s < 1`이면 사망과 이주가 생긴다. 체감 부족은 `f = (1−s) × (1 − famine_foods)`다(가축·물고기·채집이 메우는 몫을 뺀 것). 주당 사망률은 `famine_mortality × f^famine_mortality_exponent`, 이주율은 `famine_emigration × f^지수`다. 지수가 2이면 조금 덜 먹어서는 거의 죽지 않고, 완전히 굶을 때 크게 죽는다. 이주자는 굶지 않은 이웃 영지에 정착하거나(아래 '이주') 지도 밖으로 떠난다. 도시는 연 `extra_mortality`의 추가 사망이 있고, 일손이 남는 농촌에서 연 `rural_migration`씩 사람이 올라온다. |
| 교역 | 매주 곡물이 최대 `max_hops`(기본 3)개 간선 떨어진 영지까지 간다. 경로는 운송비(`cost_per_km × 거리`)와 국경 관세를 합친 비용이 가장 싼 길이고, 교전 중인 간선은 지나지 못한다. 경로는 국경이나 전쟁이 바뀔 때만 다시 계산한다. `max_hops: 1`이면 이웃끼리만 교역한다. **수출**은 재고가 `다음 수확까지 필요량 + reserve_weeks`를 넘는 부분만, **수입**은 `다음 수확까지 필요량 + buffer_weeks`에 모자란 만큼만 이뤄진다. 가격차가 경로 비용을 넘을 때만 교역하고, 경로마다 `edge_capacity` 용량과 경로 길이에 비례한 운송 손실을 적용한다. 도시로는 `다음 수확까지 필요량 + city.export_reserve_weeks`를 넘는 재고까지 판다. |
| 가격 | `base × (재고/안전재고)^(−elasticity)`, `[min, max]`로 제한. 재고가 많을수록 싸다. |
| 날씨 | 해마다 지역 공통 계수(평균 1.0)에 영지별 편차를 더한다. `Famine`은 지정한 해의 계수를 `1 − severity`로 강제한다(연도는 0부터 센다). |
| 국가 | 영주 세곡의 `granary_share`만큼이 국가 창고로 들어간다. 재고가 `trigger_weeks`치 필요량 아래로 떨어진 소속 영지에는 `target_weeks`치까지 구휼한다. 한 주에 풀 수 있는 양은 창고의 `max_release_per_week` 비율까지이고, 수도에서의 거리에 비례해 운송 손실이 난다. |
| 국경 | 서로 다른 국가 영지를 잇는 교역 간선에는 `border_tariff`가 운송비에 더해진다. |
| 전쟁 | `War(year, attacker, defender, duration, intensity)`는 `year`년 초에 선전포고한다. 전쟁 중에는 다음이 적용된다. (1) 두 나라 사이 교역 간선이 끊긴다. (2) 두 나라가 인구 비례 군량을 낸다. 국가 창고에서 먼저 내고, 모자라면 영지 재고를 비율대로 징발한다. (3) 접경 영지(적국과 교역 간선으로 이어진 영지)는 수확기마다 새 수확의 `raid_harvest_loss`를 약탈당하고, 그중 `plunder_share`가 적국 창고로 간다. (4) 접경 영지에 매주 `frontline_mortality`의 전쟁 사망이 난다. |
| 종전 | 매년 초 지난해 전력(`인구 × 군량 충족률`)을 비교한다. 한쪽이 `decisive_ratio`배 이상이면 승리로 끝나고, 패전국은 창고의 `reparation_share`를 배상한다. 양쪽 전력이 모두 0이면 승부가 나지 않는다. 판정이 나지 않은 채 `duration`이 지나거나 `stalemate_years`가 지나면 배상 없는 백지 강화로 끝난다. 선포한 해에는 판정하지 않는다. 동시에 여러 전쟁을 하는 나라는 전쟁마다 군량을 따로 낸다. |
| 민심 | 매주 `(1 − 민심) × recovery_per_week`만큼 회복하고 `tax_weight × (세율 − tolerated_tax)⁺ + hunger_weight × (1 − s)^hunger_exponent + requisition_weight × 징발 + war_weight × 접경 교전 강도`만큼 깎이며, `relief_weight × 구휼`만큼 오른다(징발·구휼은 영지 주간 소비 기준, 최대 1). 0.2 아래면 반란(세금·징발 거부), 0.35 위면 반란 종료, 26주 지속되면 이웃 나라 중 `세율 − faith_affinity × 그 나라 신앙의 신자 비율`이 가장 낮고 지금 나라보다도 낮은 곳으로 이탈(없으면 반란 지속, 종교를 끄면 세율만 비교). |
| 이주 | 이웃 j로 가는 비율은 `min(j 재고 주수, max_pull_weeks) / (1 + 거리 / distance_scale)`에 비례하고, 국경을 넘으면 `border_factor`배. 그 주에 굶지 않은 영지만 행선지가 되고, 교전 중인 간선은 막힌다. 떠난 사람의 `settle_share`가 도착한다. |
| 전염병 | 영지별 SIR. 새 감염 = `S × (beta × I + Σ 간선 접촉 × 이웃 I)`, 매주 감염자의 `exit_rate`가 감염 상태를 벗어나고 그중 `fatality`가 사망. 이웃 간선의 접촉 = `travel_contact` + (교역 시) `route_contact` + `war_contact × 교전 강도`. 여기에 곡물을 사고판 두 영지 사이에 `trade_contact × 오간 곡물(주간 소비 기준, 최대 1)`이 더해진다. 중계 교역이면 이웃이 아닌 두 영지에도 더해진다. 감염자가 `min_cases`명 미만이면 0으로. |
| 종교 | 영지별 신앙 비율. 매주 평화로운 이웃과 섞이고(`mixing`) 통치자 신앙으로 기울며(`conversion`), 위기(굶주림 + 감염 비율) 때 `1 + crisis_boost × 위기`배로 빨라진다. 통치자와 다른 신앙 비율만큼 민심이 `loyalty_weight`씩 깎인다. 교회는 영지 몫 수확의 `tithe`를 신자 비율대로 받고, 재고가 `trigger_weeks` 아래인 신자에게 `target_weeks`까지 구휼한 뒤, `reserve_weeks`를 넘는 몫을 신자 수 비율로 나눠 준다. 교회 창고도 `spoilage_per_week`만큼 상한다. |
| 정복 | 판정승한 나라는 패전국의 접경 영지 중 자기 영토와 교역 간선이 가장 많이 닿은 곳부터 `annex_manors`개를 병합한다. 수도는 병합할 수 없고 패전국은 영지를 최소 하나 남기므로, 나라가 소멸하지는 않는다. 병합된 영지는 인구와 재고를 그대로 갖고 승전국 세율을 따르며, 민심은 `unrest.conquered`(이교 영지면 그보다 낮게)에서 다시 시작한다. 국경 관세, 수도까지 거리, 지표의 `realm_id`가 그 시점부터 바뀐다. 강화(공물)나 백지 강화로 끝난 전쟁에서는 병합하지 않는다. |

틱 안의 순서: (연초: 지난해 전쟁 판정·정복 → 개간·황폐 → 정책 → 이벤트(날씨·기후·재해·전쟁·역병·봉기) → 재해 피해·민심 충격) → 파종 → 생산(수확기, 세곡 → 국가 창고와 도시 시장, 접경 약탈, 종자 비축) → 군량(반란 영지는 거부) → 구휼 → 교역 → 굶주린 마을의 종자 먹기 → 소비·인구(혹한의 겨울엔 더 먹고 더 상하고 얼어 죽음) → 도시 추가 사망 → 이주 정착 → 도시 상경 → 전쟁 사망 → 전염병 → 민심·반란·이탈 → 창고 부패 → 지표 기록. 반란 영지는 수확에서 세금을 내지 않는다.

이벤트는 연초마다 `YearContext`를 받는다. 그해의 날씨(`weather`)를 바꾸고, `ctx.declare_war(...)`로 전쟁을 선포할 수 있다. 진행 중인 전쟁은 해를 넘겨 이어지고, 판정이나 기한으로만 끝난다. 역병은 `ctx.infect(manor_id, share)`로 일으킨다. 우선순위는 `WeatherShock` → `ClimateShift` → `Murrain` → `Famine` → 재해(`Flood`, `Locusts`, `Raiders`, `HarshWinter`, `PeasantUprising`) → `War` → `Plague` 순이다. 새로운 이벤트도 `priority`와 `on_year_start(ctx)`만 구현하면 넣을 수 있다.

## 지표 해석

`sim.run()`과 `sim.manors()`는 틱 × 영지 단위의 long-format DataFrame을 반환한다.

| 컬럼 | 의미 |
|---|---|
| `tick`, `year`, `week` | 시간 (year·week는 0부터) |
| `manor_id` | 영지 id |
| `population` | 틱 종료 시 인구 |
| `grain_stock` | 틱 종료 시 곡물 재고 (kg) |
| `price` | 현지 곡물 가격. 1.0이면 재고가 안전재고와 같은 수준. 높을수록 부족 |
| `food_ratio` | 이번 주 식량 충족률 (1 = 충분, 0 = 완전 기아) |
| `deaths`, `emigrants` | 이번 주 기아로 인한 사망과 떠난 사람 (기근 이주 + 도시 상경) |
| `imports`, `exports` | 이번 주 교역 수입(운송 손실 차감 후)과 수출 |
| `realm_id` | 소속 국가 |
| `relief` | 이번 주 국가 창고에서 받은 구휼 곡물 |
| `requisitioned` | 이번 주 군량으로 징발된 곡물 |
| `raided` | 이번 주 약탈당한 수확 (접경 영지, 수확기만) |
| `war_deaths` | 이번 주 전쟁 사망 (접경 영지만) |
| `immigrants` | 이번 주 정착한 이주민 |
| `infected` | 감염자 비율 (주말 기준) |
| `plague_deaths` | 이번 주 역병 사망 |
| `loyalty` | 민심 (0~1) |
| `in_revolt` | 반란 중이면 1 |
| `is_city` | 도시면 1 |
| `faith` | 영지의 다수 신앙 (다른 신앙이 `majority_margin`만큼 앞서야 바뀜) |
| `conformity` | 통치자와 같은 신앙인 사람의 비율 (0~1) |
| `tithe`, `alms` | 이번 주 교회에 바친 십일조, 교회에서 받은 구휼 |
| `arable_land` | 경작지 (ha) |
| `worked_share` | 경작지 중 일손이 닿는 비율 (0~1) |
| `seed_sown`, `seed_eaten` | 이번 주 뿌린 종자, 굶주림에 먹어 버린 종자 (kg) |
| `rent_grain` | 이번 주 도시 시장에 들어온 영주 소작료 곡물 (도시만) |
| `urban_deaths` | 이번 주 도시 추가 사망 |
| `disaster_deaths` | 홍수 등 재해 사망 (재해가 난 해의 첫 주) |

`sim.unrest()`는 반란 시작(`revolt`)·종료(`revolt_ended`)·이탈(`defected`, 이전·이후 국가 포함) 기록이다.

`sim.faiths()`는 주 × 신앙 단위로 신자 수(`believers`)와 교회 창고(`church`)를, `sim.conversions()`는 영지의 다수 신앙 변화(`converted`), 통치자 개종(`ruler_converted`), 분열(`schism`), 선교(`missionaries`) 기록을 준다.

`sim.realms()`는 틱 × 국가 단위 지표다. 컬럼: `population`, `granary`(창고 재고), `war_intensity`(0이면 평화), `tax_collected`, `relief_released`, `upkeep_from_granary`, `plunder`(약탈로 받은 곡물), `reparations`(받으면 +, 내면 −), `faith`(통치자 신앙), `conformity`(인구 가중).

보조 함수:

- `fe.collapses(df, threshold=0.5)`: 인구가 첫 틱 대비 `threshold` 아래로 처음 떨어진 시점을 영지별로 구한다. 이른 순으로 정렬하며, 붕괴하지 않은 영지는 결과에 없다. "어느 영지부터 무너지는가"에 대한 직접적인 답이다.
- `fe.gini(df, col="population")`: 틱별로 영지 간 `col`의 지니 계수를 구한다. 기근 뒤 이 값이 오르면 피해가 특정 영지에 몰렸다는 뜻이다.
- `fe.losses(df, by="manor")`: 영지별 누적 기아 사망·이주(떠남·정착)·전쟁·역병·도시·재해 사망과 합계 행. `by="realm"`이면 국가별로 합산한다(각 틱의 손실을 그 시점 소속 국가에 넣으므로 정복 전후가 올바르게 갈린다).

해석할 때 참고할 점:

- **기근의 시차.** 흉작이 든 해에는 비축분으로 버티기 때문에 사망은 대개 **이듬해 봄~여름**(다음 수확 직전)에 몰린다. 기근 연도 바로 다음 해 붕괴가 많은 것은 이 때문이다.
- **교역의 효과는 기근 범위와 도시에 따라 다르다.** 도시가 없는 월드에서 일부 영지만 흉작이면 교역이 사망과 붕괴를 줄인다(테스트에서 10개 시드 합계로 확인. 배급이 생긴 뒤로는 시드 하나에서의 차이가 작다). 모든 영지가 동시에 흉작이면 나눌 잉여가 없어 효과가 거의 없다. 교역은 곡물을 재분배할 뿐 새로 만들지는 않는다.
- **도시는 기근 때 농촌의 여유분을 빨아들인다.** 도시가 낮은 비축 기준으로 사들이기 때문에, 도시가 있는 월드에서는 흉작이 든 이웃 농촌에 돌아갈 곡물이 남지 않아 교역의 기근 완화 효과가 사라진다.
- **`reserve_weeks`는 중요한 파라미터다.** 이 값을 4주처럼 작게 두면 잉여 영지가 해마다 이월 비축분을 내보낸다. 그러면 비옥도가 낮은 영지가 땅이 감당할 수 있는 수준을 넘는 인구를 떠안고, 전체 기근 때는 교역이 오히려 사망을 늘린다.

- **구휼의 효과는 전체 기간으로 판단해야 한다.** 구휼은 평년에도 비옥도 낮은 영지를 먹여 살린다. 그래서 기근 직전 인구가 더 많고, 기근 구간만 보면 사망이 오히려 많을 수 있다. 전체 기간 사망과 최종 인구로 보면 구휼이 있는 쪽이 낫다(테스트에서 10개 시드 모두 확인). 교역도 같은 이유로 구휼과 함께 켜면 기근 구간 사망을 늘릴 수 있다.
- **이주는 기근 사망을 늘리지만 총손실은 줄인다.** 정착한 이주민이 이웃의 식량을 나눠 먹으므로 기근 사망은 늘 수 있다(구휼 없이 영지 절반 기근, 시드 6개: 3,005 → 3,215). 하지만 지도 밖으로 영영 떠나는 사람이 크게 줄어, 사망과 영구 이탈을 합친 손실은 줄어든다(4,133 → 3,717). 비교할 때는 기근 사망만이 아니라 `deaths + emigrants − immigrants`를 봐야 한다.
- **전쟁과 기근은 서로를 키운다.** 군량이 국가 창고를 먼저 비우기 때문에 기근 때 풀 구휼분이 사라지고, 붕괴 수가 각각일 때의 합을 넘기도 한다. 국경이 맞닿지 않은 두 나라의 전쟁은 접경 피해 없이 군량 부담과 교역 단절만 남긴다.
- **전쟁은 창고를 불릴 수 있지만 인구는 늘 줄어든다.** 시드 8개를 평시와 비교하면 공격국 창고는 6개에서 늘었지만, 인구는 모든 경우 줄었다. 진 쪽 창고가 오히려 늘기도 한다. 인구가 줄면 구휼로 나갈 곡물도 줄기 때문이다. 창고만 보고 전쟁의 득실을 판단하면 안 된다.

## 변경 내역

**0.0.3** — 종교, 배급, 중계 교역, 연대기 추가, 버그 수정과 입력 검사 강화. **같은 시드라도 결과 수치가 다르다.** 위 실험 표는 모두 다시 돌린 값이다.

새 기능
- 배급(`rationing`): 다음 수확까지 버티기 모자란 마을은 덜 먹는다(최소 `min_ration`, 기본 60%). 기아 사망·이주는 부족분에 볼록하게 늘고(`famine_mortality_exponent`, 기본 2), 가축·물고기·채집이 부족분의 일부를 메운다(`famine_foods`, 기본 0.3). 민심의 굶주림 압력도 볼록하다(`unrest.hunger_exponent`). 0.0.2처럼 돌리려면 `fe.params({"rationing": {"enabled": False}, "population": {"famine_mortality_exponent": 1.0, "famine_foods": 0.0}, "unrest": {"hunger_exponent": 1.0, "hunger_weight": 0.08}, "trade": {"max_hops": 1}, "religion": {"enabled": False}})`을 쓴다.
- 중계 교역(`trade.max_hops`, 기본 3): 곡물이 이웃의 이웃까지, 운송비와 관세를 합친 비용이 가장 싼 길로 간다. 교전 중인 국경은 지나지 못한다. 곡물을 사고판 두 영지 사이에는 이웃이 아니어도 역병 접촉이 생긴다. `max_hops: 1`이면 0.0.2처럼 이웃끼리만 교역한다.
- 연대기: `fe.chronicle(sim)`(표), `fe.tell(sim)`(글). 한국어와 영어를 지원한다.
- 종교(`religion`, 기본 켜짐): 가상의 신앙들이 길을 따라 퍼지고 통치자 쪽으로 개종한다. 신앙이 다르면 민심이 깎이고, 반란 영지는 같은 신앙의 나라를 고르며, 정복된 이교 영지는 민심이 낮게 시작하고, 교역 비용이 오른다. 교회는 십일조를 받아 굶는 신자를 돕고 남는 몫을 나눠 준다. 새 이벤트 `fe.Schism`, `fe.Missionaries`, `fe.Crusade`, 새 행동 `fe.Convert`, `World.generate(n_faiths=)`, `sim.faiths()`, `sim.conversions()`, 지표 `faith`·`conformity`·`tithe`·`alms`. 내장 정책도 신앙을 본다.

버그 수정과 검사
- 인구가 거의 0이 된 영지에서 재고가 반올림으로 아주 작은 음수가 되면 식량 충족률이 터무니없는 값(예: −10⁵⁵)이 되던 문제를 고쳤다.
- 장부를 깨거나 크래시를 내던 파라미터 값을 이제 시작할 때 거부한다. 모든 숫자는 유한하고 0 이상이어야 하고, 비율은 0~1, 최소·최대 쌍은 순서대로여야 한다. 기아 사망률 + 이주율은 1 이하, 반란 시작 기준은 종료 기준 이하여야 한다. `World.generate`도 같은 검사를 한다.
- 이벤트에 없는 영지 id를 주면(`Famine(affected=[999])` 등) 조용히 무시하지 않고 `ValueError`를 낸다. 음수나 소수 연도도 거부한다.
- 앞뒤가 맞지 않는 월드(없는 영지를 가리키는 간선, 중복 id 등)는 `ValueError: invalid world`로 거부한다. 스냅샷은 같은 월드에만 복원된다(월드 지문 `World.fingerprint()` 비교).
- 앙상블에서 결과가 조용히 비거나 섞이던 실수를 오류로 바꿨다. 기준 배치 이름 오타, 월드에 없는 초점 국가, 이름이 겹치는 배치, 빈 시드 목록이 해당한다. `fe.Sim`에 잘못된 종류의 값(월드 자리에 dict, 이벤트 자리에 클래스나 `None`, 파라미터 자리에 파일 경로 등)을 넣으면 고치는 방법을 알려 주는 `TypeError`를 낸다. 그래프 함수도 없는 컬럼이나 기록이 없는 주를 명확히 알린다.
- `sim.run()`은 0 이상의 정수 연도만 받는다. 정책의 `decide`가 `None`을 돌려주면 "아무것도 안 함"으로 처리한다. `fe.gini`와 `fe.realm_summary`가 실행 전 빈 상태에서도 동작한다.
- `sim.snapshot()`이 복사본이 아니어서, 스냅샷을 메모리에 둔 채 시뮬레이션을 더 돌리면 스냅샷의 기록 목록까지 함께 늘어났다. 그 스냅샷으로 되돌리면 오류가 났다(JSON으로 저장했다 읽은 스냅샷은 문제없었다). 이제 완전한 복사본을 돌려준다.
- `sim.unrest()`의 `to_realm_id`가 빈칸(None)이 섞인 object 컬럼이었다. 이제 다른 id 컬럼처럼 정수(빈칸 허용, `Int64`)다.
- 반란 영지가 자기 영주보다 세율이 높은 나라로도 이탈하던 문제를 고쳤다. 굶주림으로 반란이 번지면 두 나라 영지들이 몇 주 사이에 서로 맞바꾸듯 넘어가는 일이 있었다. 이제는 세율이 더 낮은 이웃 나라가 있을 때만 이탈하고, 없으면 반란 상태로 남는다. 반란과 전쟁이 많은 시나리오의 결과 수치가 바뀐다.

**0.0.2** — 0.0.1 이후 크게 넓혔다. **API 이름이 바뀌었고, 같은 시드라도 결과 수치가 다르다.**

새 기능
- 정복(국경 변화), 민심과 반란(반란 영지의 이웃 나라 이탈), 전염병(`fe.Plague`), 기근 이주와 도시 상경.
- 농사 세밀화: 노동력에 따른 수확, 종자(수확 때 비축, 굶주리면 먹어 버림), 개간과 황폐.
- 도시(`World.generate(..., n_cities=)`, `Manor.is_city`): 영주 소작료·시장 교역·상경으로 먹고사는 시장 도시. 도시와 반경 25km 안 영지 사이에 도로(교역 간선)가 추가된다.
- 기후와 재해: `fe.ClimateShift`, `fe.Flood`, `fe.Locusts`, `fe.Raiders`, `fe.Murrain`, `fe.HarshWinter`, `fe.PeasantUprising`.
- 그래프: `feudal.plot` (`pip install feudal[plot]`).
- 기록과 지표: `sim.unrest()`, 지표 컬럼 `immigrants`, `infected`, `plague_deaths`, `loyalty`, `in_revolt`, `is_city`, `arable_land`, `worked_share`, `seed_sown`, `seed_eaten`, `rent_grain`, `urban_deaths`, `disaster_deaths`. 관측에 `loyalty`, `manors_in_revolt`, `last_year_plague_deaths`. 국가별 요약에 `manors_start`, `manors_end`, `final_arable_land`, `plague_deaths`, `immigrants`, `revolt_weeks`.
- `Cautious`는 민심이 낮거나 반란이 있으면 전쟁을 시작하지 않고, `Steward`는 그럴 때 세율을 낮춘다.

이름 변경 — 모든 공개 이름을 최상위(`import feudal as fe`)로 올렸다. 하위 모듈 import도 계속 동작한다.

| 0.0.1 | 0.0.2 |
|---|---|
| `Simulation` | `fe.Sim` |
| `sim.dataframe()` / `realm_dataframe()` / `wars_dataframe()` / `decisions_dataframe()` | `sim.manors()` / `realms()` / `wars()` / `decisions()` |
| `load_params()` / `merge_params(load_params(), {...})` / `load_params(path)` | `fe.params()` / `fe.params({...})` / `fe.params(path=...)` |
| `collapse_order(df)` | `fe.collapses(df)` |
| `total_losses(df)` / `realm_losses(df)` | `fe.losses(df)` / `fe.losses(df, by="realm")` (국가별에도 합계 행이 붙는다) |
| `RealmObservation` | `fe.Observation` |
| 모듈 `feudal.params` | `feudal.config` |

동작 변경
- `fe.params(path=...)`는 파일을 패키지 기본값 위에 덮어쓴다. 바꿀 값만 적은 짧은 파일을 쓸 수 있고, 0.0.1 때 만든 파일도 새 섹션은 기본값으로 채워져 그대로 동작한다.
- `base_yield`의 뜻이 순수확(500)에서 종자를 포함한 총수확(600)으로 바뀌었다. 세금은 종자분을 뺀 수확에 매긴다. **0.0.1의 params.yaml을 통째로 복사해 쓰고 있다면** 그 파일의 `base_yield: 500`이 이제 총수확으로 읽혀 오류 없이 더 가난한 세계가 된다. 600으로 고치거나, 바꾼 값만 남긴 짧은 파일로 바꾸는 것이 좋다.
- `emigrants`에 도시 상경이 포함된다. `migration.enabled: false`는 기근 이주와 상경을 함께 끈다.
- 강화를 청한 쪽이 공물을 낸다(`war.peace_tribute_share`). 동시에 여러 전쟁을 하면 전쟁마다 군량을 낸다. 양쪽 전력이 모두 0이면 판정승이 나지 않는다.
- 잘못된 파라미터·이벤트·정책 값(범위 밖 비율, NaN 세율 등)은 `ValueError`나 거부된 행동으로 처리된다.
- 결과는 월드를 어떤 순서로 저장했는지, 국가 id를 무엇으로 붙였는지와 무관하다. `Sim`은 월드를 영지 id·국가 id 순으로 정렬한 형태(`World.canonical()`)로 돌리고, 동점(정복 대상, 약탈물 행선지, 역병 기본 발원지 등)은 배열 위치가 아니라 영지 id로 가린다.
- 국가 수를 영지 수보다 많이 요청하면 영지 수로 줄인다(나라마다 수도가 필요하다).
- 지표 표의 정수 컬럼(`tick`, `manor_id`, `realm_id` 등)은 모든 플랫폼에서 int64다(0.0.1은 Windows + numpy 1.x에서 int32였다).
- 새 기능 대부분이 기본으로 켜져 있다. 끄려면 `World.generate(..., n_cities=0)`과 `fe.params({"unrest": {"enabled": False}, "migration": {"enabled": False}, "production": {"seed_per_ha": 0, "base_yield": 500, "labor": {"enabled": False}}, "land": {"enabled": False}, "war": {"annex_manors": 0}})`를 쓴다.

**0.0.1** — 첫 공개.

## 범위 밖

학습형·LLM 기반 정책, 봉신 위계, 나라의 소멸·분열(반란 영지의 독립국 수립 포함), 화폐 경제(도시는 곡물로만 먹고 산다), 여러 홉에 걸친 중계 교역, 작물·가축 구분, 이름 생성기.
