|
|
|
|
|
import time |
|
|
import threading |
|
|
import numpy as np |
|
|
import gradio as gr |
|
|
from collections import deque |
|
|
import hashlib |
|
|
import json |
|
|
import os |
|
|
from datetime import datetime |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
ENGINE_LOCK = threading.Lock() |
|
|
SIM = None |
|
|
RUNNING = False |
|
|
|
|
|
PROVENANCE_LOG = deque(maxlen=32) |
|
|
PROVENANCE_FILE = "provenance_log.jsonl" |
|
|
RUN_ID_COUNTER = 0 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
( |
|
|
EMPTY, MALE, FEMALE, BABY, WORKER, |
|
|
BUILDING, HOUSE, SHOP, ROAD, FOREST, FOOD, |
|
|
TEACHER, GUILD, SCHOOL, |
|
|
TOP_BUILDER, TOP_GATHERER, |
|
|
CITY_CENTER |
|
|
) = range(17) |
|
|
|
|
|
COLORS = { |
|
|
EMPTY: (255, 255, 255), |
|
|
MALE: (30, 144, 255), |
|
|
FEMALE: (255, 105, 180), |
|
|
BABY: (255, 235, 59), |
|
|
WORKER: (150, 150, 255), |
|
|
BUILDING: (128, 128, 128), |
|
|
HOUSE: (180, 180, 180), |
|
|
SHOP: (255, 165, 0), |
|
|
ROAD: (0, 0, 0), |
|
|
FOREST: (34, 139, 34), |
|
|
FOOD: (144, 238, 144), |
|
|
TEACHER: (128, 0, 128), |
|
|
GUILD: (102, 51, 153), |
|
|
SCHOOL: (0, 191, 255), |
|
|
TOP_BUILDER: (0, 0, 200), |
|
|
TOP_GATHERER: (200, 0, 0), |
|
|
CITY_CENTER: (255, 0, 255) |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class SimParams: |
|
|
def __init__( |
|
|
self, |
|
|
width=64, height=64, |
|
|
male_init=90, female_init=90, teacher_init=3, |
|
|
forest_fraction=0.10, food_fraction=0.06, |
|
|
baby_age_ticks=8, |
|
|
tick_rate=6, |
|
|
top_builders=8, top_gatherers=8, |
|
|
city_scan_interval=50, |
|
|
city_cluster_radius=2, |
|
|
city_building_threshold=6, |
|
|
road_connect_interval=100 |
|
|
): |
|
|
self.width = int(width) |
|
|
self.height = int(height) |
|
|
self.male_init = int(male_init) |
|
|
self.female_init = int(female_init) |
|
|
self.teacher_init = int(teacher_init) |
|
|
self.forest_fraction = float(forest_fraction) |
|
|
self.food_fraction = float(food_fraction) |
|
|
self.baby_age_ticks = int(baby_age_ticks) |
|
|
self.tick_rate = int(tick_rate) |
|
|
self.top_builders = int(top_builders) |
|
|
self.top_gatherers = int(top_gatherers) |
|
|
self.city_scan_interval = int(city_scan_interval) |
|
|
self.city_cluster_radius = int(city_cluster_radius) |
|
|
self.city_building_threshold = int(city_building_threshold) |
|
|
self.road_connect_interval = int(road_connect_interval) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def log_run(params: SimParams, seed): |
|
|
global RUN_ID_COUNTER, PROVENANCE_LOG |
|
|
RUN_ID_COUNTER += 1 |
|
|
|
|
|
base_record = { |
|
|
"run_id": RUN_ID_COUNTER, |
|
|
"timestamp_utc": datetime.utcnow().isoformat(timespec="seconds") + "Z", |
|
|
"seed": int(seed), |
|
|
"width": params.width, |
|
|
"height": params.height, |
|
|
"male_init": params.male_init, |
|
|
"female_init": params.female_init, |
|
|
"teacher_init": params.teacher_init, |
|
|
"forest_fraction": params.forest_fraction, |
|
|
"food_fraction": params.food_fraction, |
|
|
"baby_age_ticks": params.baby_age_ticks, |
|
|
"tick_rate": params.tick_rate, |
|
|
"top_builders": params.top_builders, |
|
|
"top_gatherers": params.top_gatherers, |
|
|
"city_scan_interval": params.city_scan_interval, |
|
|
"city_cluster_radius": params.city_cluster_radius, |
|
|
"city_building_threshold": params.city_building_threshold, |
|
|
"road_connect_interval": params.road_connect_interval, |
|
|
} |
|
|
|
|
|
payload = json.dumps(base_record, sort_keys=True) |
|
|
sha = hashlib.sha512(payload.encode("utf-8")).hexdigest() |
|
|
record = dict(base_record) |
|
|
record["sha512"] = sha |
|
|
|
|
|
PROVENANCE_LOG.append(record) |
|
|
try: |
|
|
with open(PROVENANCE_FILE, "a", encoding="utf-8") as f: |
|
|
f.write(json.dumps(record) + "\n") |
|
|
except Exception: |
|
|
pass |
|
|
|
|
|
def get_provenance_summary(): |
|
|
if not PROVENANCE_LOG: |
|
|
return "No runs logged yet.", None |
|
|
lines = [] |
|
|
for r in list(PROVENANCE_LOG)[-12:][::-1]: |
|
|
line = ( |
|
|
f"#{r['run_id']} | seed={r['seed']} | " |
|
|
f"size={r['width']}x{r['height']} | " |
|
|
f"m={r['male_init']}, f={r['female_init']}, T={r['teacher_init']} | " |
|
|
f"tick_rate={r['tick_rate']} | " |
|
|
f"sha512={r['sha512'][:12]}..." |
|
|
) |
|
|
lines.append(line) |
|
|
text = "\n".join(lines) |
|
|
file_path = PROVENANCE_FILE if os.path.exists(PROVENANCE_FILE) else None |
|
|
return text, file_path |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class Civilization: |
|
|
def __init__(self, params: SimParams, seed=7): |
|
|
self.params = params |
|
|
self.rng = np.random.default_rng(int(seed)) |
|
|
H, W = params.height, params.width |
|
|
self.grid = np.full((H, W), EMPTY, dtype=np.uint8) |
|
|
self.age = np.zeros_like(self.grid, dtype=np.uint16) |
|
|
self.food = np.zeros_like(self.grid, dtype=np.float32) |
|
|
self.tick = 0 |
|
|
|
|
|
|
|
|
self.city_ids = np.full((H, W), -1, dtype=np.int32) |
|
|
self.city_registry = {} |
|
|
self.next_city_id = 0 |
|
|
|
|
|
|
|
|
forest_mask = self.rng.random(self.grid.shape) < params.forest_fraction |
|
|
self.grid[forest_mask] = FOREST |
|
|
food_mask = (self.rng.random(self.grid.shape) < params.food_fraction) & (self.grid == EMPTY) |
|
|
self.grid[food_mask] = FOOD |
|
|
self.food[food_mask] = 1.0 |
|
|
|
|
|
|
|
|
empties = np.argwhere(self.grid == EMPTY) |
|
|
self.rng.shuffle(empties) |
|
|
idx = 0 |
|
|
for (y, x) in empties[idx:idx + params.male_init]: |
|
|
self.grid[y, x] = MALE |
|
|
idx += params.male_init |
|
|
for (y, x) in empties[idx:idx + params.female_init]: |
|
|
self.grid[y, x] = FEMALE |
|
|
idx += params.female_init |
|
|
|
|
|
|
|
|
for (y, x) in empties[idx:idx + params.teacher_init]: |
|
|
self.grid[y, x] = TEACHER |
|
|
idx += params.teacher_init |
|
|
|
|
|
|
|
|
empties2 = np.argwhere(self.grid == EMPTY) |
|
|
self.rng.shuffle(empties2) |
|
|
tb = min(params.top_builders, len(empties2)) |
|
|
tg = min(params.top_gatherers, max(0, len(empties2) - tb)) |
|
|
for (y, x) in empties2[:tb]: |
|
|
self.grid[y, x] = TOP_BUILDER |
|
|
for (y, x) in empties2[tb:tb + tg]: |
|
|
self.grid[y, x] = TOP_GATHERER |
|
|
|
|
|
def _neighbors8(self, y, x): |
|
|
H, W = self.grid.shape |
|
|
for dy in (-1, 0, 1): |
|
|
for dx in (-1, 0, 1): |
|
|
if dy == 0 and dx == 0: |
|
|
continue |
|
|
yield (y + dy) % H, (x + dx) % W |
|
|
|
|
|
def _any_food_nearby(self, y, x): |
|
|
if self.food[y, x] > 0: |
|
|
return True |
|
|
for ny, nx in self._neighbors8(y, x): |
|
|
if self.food[ny, nx] > 0: |
|
|
return True |
|
|
return False |
|
|
|
|
|
def _global_counts(self): |
|
|
g = self.grid |
|
|
return { |
|
|
"roads": int(np.sum(g == ROAD)), |
|
|
"houses": int(np.sum(g == HOUSE)), |
|
|
"shops": int(np.sum(g == SHOP)), |
|
|
"buildings": int(np.sum(g == BUILDING)), |
|
|
"foods": int(np.sum(g == FOOD)), |
|
|
"forests": int(np.sum(g == FOREST)), |
|
|
"cities": int(np.sum(g == CITY_CENTER)), |
|
|
} |
|
|
|
|
|
|
|
|
def detect_cities(self): |
|
|
r = self.params.city_cluster_radius |
|
|
H, W = self.grid.shape |
|
|
new_centers = [] |
|
|
|
|
|
for y in range(H): |
|
|
y0, y1 = max(0, y - r), min(H, y + r + 1) |
|
|
for x in range(W): |
|
|
x0, x1 = max(0, x - r), min(W, x + r + 1) |
|
|
window = self.grid[y0:y1, x0:x1] |
|
|
bcount = np.sum( |
|
|
(window == BUILDING) | (window == SHOP) | (window == HOUSE) | |
|
|
(window == MALE) | (window == TOP_BUILDER) |
|
|
) |
|
|
if bcount >= self.params.city_building_threshold and self.grid[y, x] != CITY_CENTER: |
|
|
new_centers.append((y, x)) |
|
|
|
|
|
|
|
|
for (y, x) in new_centers: |
|
|
cid = self.next_city_id |
|
|
self.next_city_id += 1 |
|
|
self.grid[y, x] = CITY_CENTER |
|
|
self.city_ids[y, x] = cid |
|
|
self.city_registry[cid] = { |
|
|
"center": (y, x), |
|
|
"members": set([(y, x)]), |
|
|
"last_connected": -9999 |
|
|
} |
|
|
|
|
|
|
|
|
for cid, meta in self.city_registry.items(): |
|
|
cy, cx = meta["center"] |
|
|
y0, y1 = max(0, cy - r), min(H, cy + r + 1) |
|
|
x0, x1 = max(0, cx - r), min(W, cx + r + 1) |
|
|
region = [(yy, xx) for yy in range(y0, y1) for xx in range(x0, x1)] |
|
|
for yy, xx in region: |
|
|
self.city_ids[yy, xx] = cid |
|
|
meta["members"].add((yy, xx)) |
|
|
|
|
|
def _bfs_path(self, start, goal): |
|
|
|
|
|
H, W = self.grid.shape |
|
|
sy, sx = start |
|
|
gy, gx = goal |
|
|
q = deque([(sy, sx)]) |
|
|
prev = {(sy, sx): None} |
|
|
visited = set([(sy, sx)]) |
|
|
|
|
|
def neighbors(y, x): |
|
|
|
|
|
return [ |
|
|
((y - 1) % H, x), |
|
|
((y + 1) % H, x), |
|
|
(y, (x - 1) % W), |
|
|
(y, (x + 1) % W), |
|
|
] |
|
|
|
|
|
while q: |
|
|
y, x = q.popleft() |
|
|
if (y, x) == (gy, gx): |
|
|
|
|
|
path = [] |
|
|
cur = (gy, gx) |
|
|
while cur is not None: |
|
|
path.append(cur) |
|
|
cur = prev[cur] |
|
|
path.reverse() |
|
|
return path |
|
|
for ny, nx in neighbors(y, x): |
|
|
if (ny, nx) not in visited: |
|
|
visited.add((ny, nx)) |
|
|
prev[(ny, nx)] = (y, x) |
|
|
q.append((ny, nx)) |
|
|
return [] |
|
|
|
|
|
def connect_city_roads(self): |
|
|
|
|
|
centers = [(cid, meta["center"]) for cid, meta in self.city_registry.items()] |
|
|
if len(centers) < 2: |
|
|
return |
|
|
for i in range(len(centers)): |
|
|
cid_a, (ay, ax) = centers[i] |
|
|
|
|
|
d_best, j_best = 1e9, None |
|
|
for j in range(len(centers)): |
|
|
if i == j: |
|
|
continue |
|
|
cid_b, (by, bx) = centers[j] |
|
|
d = abs(ay - by) + abs(ax - bx) |
|
|
if d < d_best: |
|
|
d_best, j_best = d, j |
|
|
if j_best is None: |
|
|
continue |
|
|
cid_b, (by, bx) = centers[j_best] |
|
|
|
|
|
|
|
|
last_a = self.city_registry[cid_a]["last_connected"] |
|
|
last_b = self.city_registry[cid_b]["last_connected"] |
|
|
if (self.tick - last_a) < self.params.road_connect_interval and (self.tick - last_b) < self.params.road_connect_interval: |
|
|
continue |
|
|
|
|
|
path = self._bfs_path((ay, ax), (by, bx)) |
|
|
for (y, x) in path: |
|
|
if self.grid[y, x] in (EMPTY, FOREST, FOOD): |
|
|
self.grid[y, x] = ROAD |
|
|
self.age[y, x] = 0 |
|
|
self.city_registry[cid_a]["last_connected"] = self.tick |
|
|
self.city_registry[cid_b]["last_connected"] = self.tick |
|
|
|
|
|
|
|
|
def step(self): |
|
|
H, W = self.grid.shape |
|
|
new_grid = self.grid.copy() |
|
|
new_age = self.age.copy() |
|
|
new_food = self.food.copy() |
|
|
|
|
|
|
|
|
new_food[self.grid == FOREST] += 0.01 |
|
|
new_food[self.grid == FOOD] += 0.005 |
|
|
new_food = np.clip(new_food, 0.0, 5.0) |
|
|
|
|
|
|
|
|
counts = self._global_counts() |
|
|
need_bootstrap = (counts["houses"] < 50) or (counts["shops"] < 20) or (counts["buildings"] < 5) |
|
|
allow_road_widen = counts["houses"] >= 30 |
|
|
|
|
|
|
|
|
pop_mask = (self.grid == MALE) | (self.grid == FEMALE) | (self.grid == BABY) |
|
|
new_food[pop_mask] -= 0.01 |
|
|
new_food = np.clip(new_food, 0.0, 5.0) |
|
|
|
|
|
|
|
|
base_mortality_p = 0.0005 |
|
|
base_decay_p = 0.0004 |
|
|
mortality_p = base_mortality_p |
|
|
decay_p = 0.0 if need_bootstrap else base_decay_p |
|
|
|
|
|
|
|
|
if self.tick % self.params.city_scan_interval == 0: |
|
|
self.detect_cities() |
|
|
if self.tick % self.params.road_connect_interval == 0: |
|
|
self.connect_city_roads() |
|
|
|
|
|
for y in range(H): |
|
|
for x in range(W): |
|
|
cell = self.grid[y, x] |
|
|
nb = [(ny, nx) for ny, nx in self._neighbors8(y, x)] |
|
|
nb_cells = [self.grid[ny, nx] for ny, nx in nb] |
|
|
candidates = [(ny, nx) for (ny, nx), c in zip(nb, nb_cells) if c == EMPTY] |
|
|
|
|
|
|
|
|
if cell in (MALE, FEMALE, BABY) and self.rng.random() < mortality_p: |
|
|
new_grid[y, x] = EMPTY |
|
|
new_age[y, x] = 0 |
|
|
continue |
|
|
|
|
|
|
|
|
if cell in (ROAD, HOUSE, SHOP, BUILDING, SCHOOL) and self.rng.random() < decay_p: |
|
|
new_grid[y, x] = EMPTY |
|
|
new_age[y, x] = 0 |
|
|
continue |
|
|
|
|
|
|
|
|
if cell in (MALE, FEMALE, BABY) and not self._any_food_nearby(y, x): |
|
|
|
|
|
did_food = False |
|
|
for ny, nx in nb: |
|
|
if self.grid[ny, nx] == FOREST: |
|
|
new_grid[ny, nx] = FOOD |
|
|
new_food[ny, nx] += 0.7 |
|
|
new_age[ny, nx] = 0 |
|
|
did_food = True |
|
|
break |
|
|
if not did_food and candidates: |
|
|
ty, tx = candidates[self.rng.integers(0, len(candidates))] |
|
|
new_grid[ty, tx] = FOOD |
|
|
new_food[ty, tx] += 0.7 |
|
|
new_age[ty, tx] = 0 |
|
|
|
|
|
|
|
|
if cell in (MALE, FEMALE): |
|
|
if (MALE in nb_cells) and (FEMALE in nb_cells): |
|
|
env_bonus = 0.01 * sum(1 for c in nb_cells if c in (FOOD, FOREST, ROAD, HOUSE, SHOP, BUILDING)) |
|
|
p_baby = min(0.03 + env_bonus, 0.25) |
|
|
if candidates and self.rng.random() < p_baby: |
|
|
ty, tx = candidates[self.rng.integers(0, len(candidates))] |
|
|
new_grid[ty, tx] = BABY |
|
|
new_age[ty, tx] = 0 |
|
|
|
|
|
|
|
|
elif cell == BABY: |
|
|
new_age[y, x] = self.age[y, x] + 1 |
|
|
if new_age[y, x] >= self.params.baby_age_ticks: |
|
|
|
|
|
if self.city_ids[y, x] >= 0 and self.rng.random() < 0.6: |
|
|
new_grid[y, x] = MALE if self.rng.random() < 0.55 else FEMALE |
|
|
else: |
|
|
new_grid[y, x] = MALE if self.rng.random() < 0.5 else FEMALE |
|
|
new_age[y, x] = 0 |
|
|
|
|
|
|
|
|
elif cell == MALE: |
|
|
upgraded = False |
|
|
|
|
|
|
|
|
if need_bootstrap and candidates: |
|
|
ty, tx = candidates[self.rng.integers(0, len(candidates))] |
|
|
target = self.rng.choice([HOUSE, HOUSE, SHOP]) |
|
|
new_grid[ty, tx] = target |
|
|
new_age[ty, tx] = 0 |
|
|
upgraded = True |
|
|
else: |
|
|
|
|
|
cid = self.city_ids[y, x] |
|
|
if cid >= 0 and candidates and self.rng.random() < 0.6: |
|
|
|
|
|
ty, tx = candidates[self.rng.integers(0, len(candidates))] |
|
|
target = self.rng.choice([HOUSE, SHOP, BUILDING]) |
|
|
new_grid[ty, tx] = target |
|
|
new_age[ty, tx] = 0 |
|
|
upgraded = True |
|
|
|
|
|
|
|
|
for ny, nx in nb: |
|
|
ncell = self.grid[ny, nx] |
|
|
if ncell == HOUSE and self.rng.random() < 0.08: |
|
|
new_grid[ny, nx] = SHOP |
|
|
new_age[ny, nx] = 0 |
|
|
upgraded = True |
|
|
elif ncell == SHOP and self.rng.random() < 0.07: |
|
|
new_grid[ny, nx] = BUILDING |
|
|
new_age[ny, nx] = 0 |
|
|
upgraded = True |
|
|
|
|
|
|
|
|
if allow_road_widen and candidates and any(self.grid[ny, nx] == ROAD for ny, nx in nb): |
|
|
if self.rng.random() < 0.12: |
|
|
ty, tx = candidates[self.rng.integers(0, len(candidates))] |
|
|
new_grid[ty, tx] = ROAD |
|
|
new_age[ty, tx] = 0 |
|
|
upgraded = True |
|
|
|
|
|
|
|
|
if not upgraded and cid < 0 and candidates and self.city_registry: |
|
|
centers = [meta["center"] for meta in self.city_registry.values()] |
|
|
cy, cx = min(centers, key=lambda c: abs(c[0]-y)+abs(c[1]-x)) |
|
|
ty, tx = min(candidates, key=lambda p: abs(p[0]-cy)+abs(p[1]-cx)) |
|
|
new_grid[ty, tx] = ROAD |
|
|
new_age[ty, tx] = 0 |
|
|
upgraded = True |
|
|
|
|
|
|
|
|
if not upgraded and candidates: |
|
|
target = self.rng.choice([ROAD, HOUSE, SHOP]) |
|
|
ty, tx = candidates[self.rng.integers(0, len(candidates))] |
|
|
new_grid[ty, tx] = target |
|
|
new_age[ty, tx] = 0 |
|
|
|
|
|
|
|
|
for ny, nx in nb: |
|
|
if self.grid[ny, nx] == FOREST and self.rng.random() < 0.02: |
|
|
new_grid[ny, nx] = EMPTY |
|
|
new_age[ny, nx] = 0 |
|
|
|
|
|
|
|
|
elif cell == FEMALE: |
|
|
converted = False |
|
|
for ny, nx in nb: |
|
|
if self.grid[ny, nx] == FOREST: |
|
|
new_grid[ny, nx] = FOOD |
|
|
new_food[ny, nx] += 0.6 |
|
|
new_age[ny, nx] = 0 |
|
|
converted = True |
|
|
break |
|
|
if candidates: |
|
|
|
|
|
ty, tx = candidates[self.rng.integers(0, len(candidates))] |
|
|
new_grid[ty, tx] = FOREST |
|
|
new_age[ty, tx] = 0 |
|
|
if not converted and candidates: |
|
|
ty, tx = candidates[self.rng.integers(0, len(candidates))] |
|
|
new_grid[ty, tx] = FOOD |
|
|
new_food[ty, tx] += 0.5 |
|
|
new_age[ty, tx] = 0 |
|
|
|
|
|
|
|
|
elif cell == TEACHER: |
|
|
if candidates and self.rng.random() < 0.003: |
|
|
ty, tx = candidates[self.rng.integers(0, len(candidates))] |
|
|
new_grid[ty, tx] = SCHOOL |
|
|
new_age[ty, tx] = 0 |
|
|
|
|
|
|
|
|
elif cell == TOP_BUILDER: |
|
|
|
|
|
if need_bootstrap and candidates: |
|
|
ty, tx = candidates[self.rng.integers(0, len(candidates))] |
|
|
new_grid[ty, tx] = self.rng.choice([HOUSE, SHOP, BUILDING]) |
|
|
new_age[ty, tx] = 0 |
|
|
for ny, nx in nb: |
|
|
ncell = self.grid[ny, nx] |
|
|
if ncell == HOUSE: |
|
|
new_grid[ny, nx] = SHOP |
|
|
new_age[ny, nx] = 0 |
|
|
elif ncell == SHOP: |
|
|
new_grid[ny, nx] = BUILDING |
|
|
new_age[ny, nx] = 0 |
|
|
else: |
|
|
|
|
|
cid = self.city_ids[y, x] |
|
|
if cid >= 0 and candidates: |
|
|
ty, tx = candidates[self.rng.integers(0, len(candidates))] |
|
|
new_grid[ty, tx] = self.rng.choice([HOUSE, SHOP, BUILDING]) |
|
|
new_age[ty, tx] = 0 |
|
|
elif allow_road_widen and candidates: |
|
|
ty, tx = candidates[self.rng.integers(0, len(candidates))] |
|
|
new_grid[ty, tx] = ROAD |
|
|
new_age[ty, tx] = 0 |
|
|
|
|
|
for ny, nx in nb: |
|
|
ncell = self.grid[ny, nx] |
|
|
if ncell == HOUSE: |
|
|
new_grid[ny, nx] = SHOP |
|
|
new_age[ny, nx] = 0 |
|
|
elif ncell == SHOP: |
|
|
new_grid[ny, nx] = BUILDING |
|
|
new_age[ny, nx] = 0 |
|
|
|
|
|
elif cell == TOP_GATHERER: |
|
|
if candidates: |
|
|
ty, tx = candidates[self.rng.integers(0, len(candidates))] |
|
|
target = self.rng.choice([FOOD, FOREST]) |
|
|
new_grid[ty, tx] = target |
|
|
new_age[ty, tx] = 0 |
|
|
if target == FOOD: |
|
|
new_food[ty, tx] += 0.6 |
|
|
for ny, nx in nb: |
|
|
if self.grid[ny, nx] == FOREST: |
|
|
new_grid[ny, nx] = FOOD |
|
|
new_food[ny, nx] += 0.6 |
|
|
new_age[ny, nx] = 0 |
|
|
if candidates: |
|
|
ty, tx = candidates[self.rng.integers(0, len(candidates))] |
|
|
new_grid[ty, tx] = FOREST |
|
|
new_age[ty, tx] = 0 |
|
|
|
|
|
|
|
|
if cell in (ROAD, HOUSE, SHOP, BUILDING, SCHOOL, CITY_CENTER): |
|
|
new_age[y, x] = self.age[y, x] + 1 |
|
|
|
|
|
|
|
|
self.grid = new_grid |
|
|
self.age = new_age |
|
|
self.food = new_food |
|
|
self.tick += 1 |
|
|
|
|
|
def render_image(self, scale=4): |
|
|
H, W = self.grid.shape |
|
|
img = np.zeros((H, W, 3), dtype=np.uint8) |
|
|
for code, color in COLORS.items(): |
|
|
mask = (self.grid == code) |
|
|
if np.any(mask): |
|
|
img[mask] = np.array(color, dtype=np.uint8) |
|
|
return np.repeat(np.repeat(img, scale, axis=0), scale, axis=1) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def start_engine(width, height, seed, |
|
|
male_init, female_init, teacher_init, |
|
|
forest_fraction, food_fraction, |
|
|
baby_age_ticks, tick_rate, |
|
|
top_builders, top_gatherers, |
|
|
city_scan_interval, city_cluster_radius, |
|
|
city_building_threshold, road_connect_interval): |
|
|
global SIM, RUNNING |
|
|
params = SimParams( |
|
|
width=width, height=height, |
|
|
male_init=male_init, female_init=female_init, teacher_init=teacher_init, |
|
|
forest_fraction=forest_fraction, food_fraction=food_fraction, |
|
|
baby_age_ticks=baby_age_ticks, tick_rate=tick_rate, |
|
|
top_builders=top_builders, top_gatherers=top_gatherers, |
|
|
city_scan_interval=city_scan_interval, city_cluster_radius=city_cluster_radius, |
|
|
city_building_threshold=city_building_threshold, road_connect_interval=road_connect_interval |
|
|
) |
|
|
with ENGINE_LOCK: |
|
|
SIM = Civilization(params, seed=seed) |
|
|
RUNNING = True |
|
|
log_run(params, seed) |
|
|
|
|
|
def loop(): |
|
|
global SIM, RUNNING |
|
|
sleep_s = max(0.05, 1.0 / max(1, int(tick_rate))) |
|
|
while RUNNING: |
|
|
with ENGINE_LOCK: |
|
|
if SIM is not None: |
|
|
SIM.step() |
|
|
time.sleep(sleep_s) |
|
|
|
|
|
threading.Thread(target=loop, daemon=True).start() |
|
|
return f"Engine started {int(width)}x{int(height)} | tick_rate={int(tick_rate)}/s" |
|
|
|
|
|
def stop_engine(): |
|
|
global RUNNING |
|
|
RUNNING = False |
|
|
return "Engine stopped." |
|
|
|
|
|
def reset_engine(): |
|
|
stop_engine() |
|
|
globals()["SIM"] = None |
|
|
return "Reset complete." |
|
|
|
|
|
def compute_stability(pop, houses, shops, buildings, foods, schools, teachers): |
|
|
capacity = houses * 2 + shops * 3 + buildings * 4 + schools * 2 + foods * 1 |
|
|
if pop <= 0: |
|
|
return 0.0, "Empty" |
|
|
base_ratio = capacity / float(pop) |
|
|
stability = max(0.0, min(1.0, base_ratio / 4.0)) |
|
|
teacher_bonus = 1.0 + 0.03 * min(teachers, 20) |
|
|
stability *= teacher_bonus |
|
|
stability = max(0.0, min(1.0, stability)) |
|
|
if stability >= 0.7: |
|
|
label = "Stable" |
|
|
elif stability >= 0.4: |
|
|
label = "At Risk" |
|
|
else: |
|
|
label = "Fragile" |
|
|
return stability, label |
|
|
|
|
|
def get_grid(scale): |
|
|
with ENGINE_LOCK: |
|
|
if SIM is None: |
|
|
return None, "Engine not running." |
|
|
img = SIM.render_image(scale=int(scale)) |
|
|
g = SIM.grid |
|
|
counts = SIM._global_counts() |
|
|
|
|
|
pop = int(np.sum(np.isin(g, [MALE, FEMALE, BABY]))) |
|
|
roads = counts["roads"] |
|
|
houses = counts["houses"] |
|
|
shops = counts["shops"] |
|
|
buildings = counts["buildings"] |
|
|
foods = counts["foods"] |
|
|
forests = counts["forests"] |
|
|
schools = int(np.sum(g == SCHOOL)) |
|
|
top_b = int(np.sum(g == TOP_BUILDER)) |
|
|
top_g = int(np.sum(g == TOP_GATHERER)) |
|
|
cities = counts["cities"] |
|
|
teachers = int(np.sum(g == TEACHER)) |
|
|
guilds = int(np.sum(g == GUILD)) |
|
|
bootstrap = "ON" if (houses < 50 or shops < 20 or buildings < 5) else "OFF" |
|
|
|
|
|
stability, stability_label = compute_stability(pop, houses, shops, buildings, foods, schools, teachers) |
|
|
|
|
|
stats = ( |
|
|
f"Tick={SIM.tick} | Pop={pop} | Stability={stability:.2f} ({stability_label}) | " |
|
|
f"Teachers={teachers} | Guilds={guilds} | Roads={roads} | Houses={houses} | Shops={shops} | " |
|
|
f"Buildings={buildings} | FoodCells={foods} | Forests={forests} | Schools={schools} | " |
|
|
f"TopBuilders={top_b} | TopGatherers={top_g} | Cities={cities} | Bootstrap={bootstrap}" |
|
|
) |
|
|
return img, stats |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def apply_preset(preset_name, |
|
|
width, height, seed, tick_rate, |
|
|
male_init, female_init, teacher_init, |
|
|
forest_fraction, food_fraction, |
|
|
baby_age_ticks, |
|
|
top_builders, top_gatherers, |
|
|
city_scan_interval, city_cluster_radius, |
|
|
city_building_threshold, road_connect_interval): |
|
|
|
|
|
w = width |
|
|
h = height |
|
|
s = seed |
|
|
tr = tick_rate |
|
|
m = male_init |
|
|
f = female_init |
|
|
t = teacher_init |
|
|
ff = forest_fraction |
|
|
fd = food_fraction |
|
|
bat = baby_age_ticks |
|
|
tb = top_builders |
|
|
tg = top_gatherers |
|
|
csi = city_scan_interval |
|
|
ccr = city_cluster_radius |
|
|
cbt = city_building_threshold |
|
|
rci = road_connect_interval |
|
|
|
|
|
if preset_name == "Balanced City Growth": |
|
|
w, h = 64, 64 |
|
|
s = 7 |
|
|
tr = 6 |
|
|
m, f, t = 120, 120, 3 |
|
|
ff, fd = 0.10, 0.06 |
|
|
bat = 8 |
|
|
tb, tg = 8, 8 |
|
|
csi, ccr, cbt, rci = 50, 2, 6, 100 |
|
|
elif preset_name == "No Teachers (Instinct Only)": |
|
|
w, h = 64, 64 |
|
|
s = 11 |
|
|
tr = 6 |
|
|
m, f, t = 140, 140, 0 |
|
|
ff, fd = 0.12, 0.06 |
|
|
bat = 8 |
|
|
tb, tg = 8, 8 |
|
|
csi, ccr, cbt, rci = 50, 2, 6, 100 |
|
|
elif preset_name == "Teacher Surge (Guidance Heavy)": |
|
|
w, h = 64, 64 |
|
|
s = 21 |
|
|
tr = 6 |
|
|
m, f, t = 110, 110, 18 |
|
|
ff, fd = 0.10, 0.06 |
|
|
bat = 8 |
|
|
tb, tg = 10, 10 |
|
|
csi, ccr, cbt, rci = 40, 2, 5, 80 |
|
|
elif preset_name == "Resource Shock (Sparse Food)": |
|
|
w, h = 64, 64 |
|
|
s = 33 |
|
|
tr = 6 |
|
|
m, f, t = 120, 120, 4 |
|
|
ff, fd = 0.06, 0.02 |
|
|
bat = 8 |
|
|
tb, tg = 8, 8 |
|
|
csi, ccr, cbt, rci = 60, 2, 6, 120 |
|
|
elif preset_name == "Dense Grid (Urban Pressure)": |
|
|
w, h = 96, 96 |
|
|
s = 5 |
|
|
tr = 8 |
|
|
m, f, t = 260, 260, 6 |
|
|
ff, fd = 0.08, 0.05 |
|
|
bat = 8 |
|
|
tb, tg = 12, 12 |
|
|
csi, ccr, cbt, rci = 40, 3, 8, 80 |
|
|
|
|
|
return ( |
|
|
int(w), int(h), int(s), int(tr), |
|
|
int(m), int(f), int(t), |
|
|
float(ff), float(fd), |
|
|
int(bat), |
|
|
int(tb), int(tg), |
|
|
int(csi), int(ccr), |
|
|
int(cbt), int(rci), |
|
|
) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
with gr.Blocks(title="Conscious Agent Civilization — Bootstrap Densification") as demo: |
|
|
gr.Markdown("# Conscious Agent Civilization — Bootstrap Densification, Cities, and Roads") |
|
|
|
|
|
with gr.Row(): |
|
|
width = gr.Slider(32, 256, value=64, step=16, label="Grid width") |
|
|
height = gr.Slider(32, 256, value=64, step=16, label="Grid height") |
|
|
seed = gr.Number(value=7, label="Seed", precision=0) |
|
|
tick_rate = gr.Slider(1, 30, value=6, step=1, label="Ticks per second") |
|
|
|
|
|
with gr.Row(): |
|
|
male_init = gr.Slider(10, 400, value=120, step=10, label="Initial males") |
|
|
female_init = gr.Slider(10, 400, value=120, step=10, label="Initial females") |
|
|
teacher_init = gr.Slider(0, 50, value=3, step=1, label="Initial teachers") |
|
|
forest_fraction = gr.Slider(0.0, 0.6, value=0.10, step=0.02, label="Forest fraction") |
|
|
food_fraction = gr.Slider(0.0, 0.3, value=0.06, step=0.01, label="Food fraction") |
|
|
baby_age_ticks = gr.Slider(4, 24, value=8, step=1, label="Baby→Adult ticks") |
|
|
|
|
|
with gr.Row(): |
|
|
top_builders = gr.Slider(0, 50, value=8, step=1, label="Top trained builders") |
|
|
top_gatherers = gr.Slider(0, 50, value=8, step=1, label="Top trained gatherers") |
|
|
|
|
|
with gr.Row(): |
|
|
preset = gr.Dropdown( |
|
|
["Custom / manual", "Balanced City Growth", "No Teachers (Instinct Only)", |
|
|
"Teacher Surge (Guidance Heavy)", "Resource Shock (Sparse Food)", "Dense Grid (Urban Pressure)"], |
|
|
value="Custom / manual", |
|
|
label="Scenario preset" |
|
|
) |
|
|
|
|
|
with gr.Accordion("City mechanics", open=True): |
|
|
city_scan_interval = gr.Slider(10, 500, value=50, step=10, label="City scan interval (ticks)") |
|
|
city_cluster_radius = gr.Slider(1, 6, value=2, step=1, label="City cluster radius") |
|
|
city_building_threshold = gr.Slider(3, 30, value=6, step=1, label="City building threshold") |
|
|
road_connect_interval = gr.Slider(20, 1000, value=100, step=20, label="Road connect interval (ticks)") |
|
|
|
|
|
run_btn = gr.Button("Run simulation", variant="primary") |
|
|
stop_btn = gr.Button("Stop") |
|
|
reset_btn = gr.Button("Reset") |
|
|
|
|
|
with gr.Accordion("Live grid", open=True): |
|
|
scale = gr.Slider(1, 8, value=4, step=1, label="Display scale") |
|
|
img_out = gr.Image(type="numpy", label="Grid") |
|
|
stats_out = gr.Textbox(label="Stats", lines=3) |
|
|
refresh_btn = gr.Button("Refresh grid") |
|
|
|
|
|
with gr.Accordion("Run provenance (config + SHA-512)", open=False): |
|
|
prov_text = gr.Textbox(label="Recent runs", lines=8) |
|
|
prov_file = gr.File(label="Provenance log (.jsonl)") |
|
|
prov_refresh = gr.Button("Refresh provenance") |
|
|
|
|
|
|
|
|
run_btn.click( |
|
|
start_engine, |
|
|
inputs=[ |
|
|
width, height, seed, |
|
|
male_init, female_init, teacher_init, |
|
|
forest_fraction, food_fraction, |
|
|
baby_age_ticks, tick_rate, |
|
|
top_builders, top_gatherers, |
|
|
city_scan_interval, city_cluster_radius, |
|
|
city_building_threshold, road_connect_interval |
|
|
], |
|
|
outputs=[] |
|
|
) |
|
|
stop_btn.click(stop_engine, inputs=[], outputs=[]) |
|
|
reset_btn.click(reset_engine, inputs=[], outputs=[]) |
|
|
|
|
|
refresh_btn.click(get_grid, inputs=[scale], outputs=[img_out, stats_out]) |
|
|
demo.load(get_grid, inputs=[scale], outputs=[img_out, stats_out]) |
|
|
|
|
|
preset.change( |
|
|
apply_preset, |
|
|
inputs=[ |
|
|
preset, |
|
|
width, height, seed, tick_rate, |
|
|
male_init, female_init, teacher_init, |
|
|
forest_fraction, food_fraction, |
|
|
baby_age_ticks, |
|
|
top_builders, top_gatherers, |
|
|
city_scan_interval, city_cluster_radius, |
|
|
city_building_threshold, road_connect_interval |
|
|
], |
|
|
outputs=[ |
|
|
width, height, seed, tick_rate, |
|
|
male_init, female_init, teacher_init, |
|
|
forest_fraction, food_fraction, |
|
|
baby_age_ticks, |
|
|
top_builders, top_gatherers, |
|
|
city_scan_interval, city_cluster_radius, |
|
|
city_building_threshold, road_connect_interval |
|
|
] |
|
|
) |
|
|
|
|
|
prov_refresh.click( |
|
|
get_provenance_summary, |
|
|
inputs=[], |
|
|
outputs=[prov_text, prov_file] |
|
|
) |
|
|
demo.load( |
|
|
get_provenance_summary, |
|
|
inputs=[], |
|
|
outputs=[prov_text, prov_file] |
|
|
) |
|
|
|
|
|
if __name__ == "__main__": |
|
|
demo.launch(server_name="0.0.0.0", server_port=7860) |
|
|
|