DeltaZN
feat: improved perception
54db442
Raw
History Blame Contribute Delete
12.3 kB
from __future__ import annotations
import random
from world_simulator.config import GameConfig
from world_simulator.domain import (
Beast,
CountryState,
House,
MemoryEntry,
Npc,
ResourceNode,
Terrain,
TreasuryState,
Vec3,
WorldState,
)
from world_simulator.simulation.mechanics import BLOCK_SIZE, GRID_BLOCKS
from world_simulator.simulation.roles import normalize_role
_NAMES = [
"Ada",
"Boris",
"Cora",
"Dima",
"Elena",
"Farid",
"Gita",
"Hana",
"Ivan",
"Juno",
"Kira",
"Lena",
"Mira",
"Niko",
"Oleg",
"Pavel",
"Raya",
"Sofia",
"Toma",
"Vera",
]
_ROLES = ["citizen"]
_QWEN_NAMES = ["Kira", "Lena", "Mira", "Niko", "Oleg", "Pavel"]
def create_world(config: GameConfig) -> WorldState:
rng = random.Random(config.world.seed)
terrain = Terrain(
kind=config.world.terrain,
width=config.world.width,
depth=config.world.depth,
)
village_npcs = [
_spawn_npc(index=index, config=config, rng=rng, country_id="nemotron")
for index in range(config.npcs.count)
]
qwen_npcs = _spawn_qwen_npcs(config, count=config.npcs.count) if config.world.survival else []
houses = _spawn_houses(config)
if config.world.survival:
_seed_survival_relationships(village_npcs + qwen_npcs)
_assign_starting_homes(village_npcs + qwen_npcs, houses)
_initialize_importance(village_npcs + qwen_npcs)
all_npcs = village_npcs + qwen_npcs
population = sum(1 for npc in all_npcs if npc.health > 0)
world = WorldState(
tick=0,
seed=config.world.seed,
terrain=terrain,
npcs=all_npcs,
resource_nodes=_spawn_resource_nodes(config),
beasts=_spawn_beasts(config),
houses=houses,
countries=_spawn_countries(config, all_npcs),
population=population,
population_cap=max(24, population + 8),
peak_population=population,
overseer_mode="off",
overseer_cycle_ticks=0,
)
_assign_initial_rulers(world)
return world
def _spawn_npc(*, index: int, config: GameConfig, rng: random.Random, country_id: str) -> Npc:
half_width = config.world.width / 2
half_depth = config.world.depth / 2
name = _NAMES[index % len(_NAMES)]
role = normalize_role(_ROLES[index % len(_ROLES)])
if config.world.survival:
# Village is anchored on the left side of the map.
village_x = -(half_width * 2 / 3)
ring = index % 6
offsets = [
(0.0, 0.0),
(1.2, 0.8),
(-1.0, -0.9),
(3.4, 0.0),
(-3.4, 0.0),
(0.0, 3.4),
]
dx, z = offsets[ring]
x = village_x + dx
else:
x = _random_block_center(rng, half_width=half_width)
z = _random_block_center(rng, half_width=half_depth)
npc = Npc(
id=f"npc-{index + 1:03d}",
name=name,
role=role,
position=Vec3(x=x, y=0.0, z=z),
country_id=country_id,
attack_damage=rng.randint(5, 15),
memory=[MemoryEntry(tick=0, text=f"{name} arrived as a {role}.")],
age=60 if config.world.survival else 0,
max_age=_random_max_age(config.world.seed, index),
)
if config.world.survival:
# Independent per-NPC RNG so survival inventory never perturbs the shared
# position/damage stream (keeps non-survival spawning deterministic).
inv_rng = random.Random(config.world.seed + index)
npc.hunger = 0.0 if index == 0 else 20.0 + inv_rng.randint(0, 10)
npc.safety = 100.0 if index == 0 else 0.0
npc.inventory_food = 2 if index == 0 else inv_rng.randint(0, 2)
npc.inventory_herbs = inv_rng.randint(0, 1)
# Weapons stay scarce: citizens start unarmed and must gather them from the
# (limited) weapon nodes during play.
npc.inventory_weapon = 0
if index < 2:
npc.inventory_wood = 5
return npc
def _spawn_resource_nodes(config: GameConfig) -> list[ResourceNode]:
if not config.world.survival:
return []
rng = random.Random(f"{config.world.seed}:initial_resources")
# Weapons are NOT placed randomly: one node is mirrored near each base so both
# nations have equal, but still limited, access to arming up (see below).
counts = {"food": 4, "herbs": 2, "wood": 3}
max_amounts = {"food": 5, "herbs": 4, "wood": 6, "weapon": 2}
nodes: list[ResourceNode] = []
village_x = -(config.world.width / 2 * 2 / 3)
village_food_offsets = [
(2 * BLOCK_SIZE, BLOCK_SIZE),
(-2 * BLOCK_SIZE, 2 * BLOCK_SIZE),
(BLOCK_SIZE, -2 * BLOCK_SIZE),
]
for i, (dx, dz) in enumerate(village_food_offsets, start=1):
nodes.append(
ResourceNode(
id=f"res_food_village_{i}",
resource_type="food",
position=Vec3(x=round(village_x + dx, 3), y=0.0, z=round(dz, 3)),
amount=5,
max_amount=5,
)
)
for resource_type, count in counts.items():
for index in range(1, count + 1):
max_amount = max_amounts[resource_type]
nodes.append(
ResourceNode(
id=f"res_{resource_type}_{index}",
resource_type=resource_type,
position=_random_world_position(
rng,
half_width=config.world.width / 2,
half_depth=config.world.depth / 2,
),
amount=rng.randint(max(1, max_amount // 2), max_amount),
max_amount=max_amount,
)
)
# One weapon node per flank, mirrored across the x-axis so neither nation is
# favoured. Offset off the base centre so it does not sit on the home/treasury.
qwen_x = config.world.width / 2 * 2 / 3
weapon_max = max_amounts["weapon"]
weapon_z = -2 * BLOCK_SIZE
for index, base_x in enumerate((-qwen_x, qwen_x), start=1):
nodes.append(
ResourceNode(
id=f"res_weapon_{index}",
resource_type="weapon",
position=Vec3(x=round(base_x, 3), y=0.0, z=round(weapon_z, 3)),
amount=weapon_max,
max_amount=weapon_max,
)
)
return nodes
def _spawn_beasts(config: GameConfig) -> list[Beast]:
if not config.world.survival:
return []
# One beast on each flank so both nations face an equal threat: beast_1 near
# the Qwen side (+x), beast_2 mirrored near the Nemotron side (-x).
edge_x = config.world.width / 2 - BLOCK_SIZE
return [
Beast(
"beast_1",
Vec3(x=edge_x, y=0.0, z=0.0),
health=60.0,
damage=9.0,
),
Beast(
"beast_2",
Vec3(x=-edge_x, y=0.0, z=0.0),
health=60.0,
damage=9.0,
),
]
def _spawn_houses(config: GameConfig) -> list[House]:
if not config.world.survival:
return []
village_x = -(config.world.width / 2 * 2 / 3)
qwen_x = config.world.width / 2 * 2 / 3
return [
House(
id="house_nemotron_001",
position=Vec3(x=village_x, y=0.0, z=0.0),
hp=60.0,
max_hp=60.0,
state="completed",
build_progress=10,
capacity=3,
),
House(
id="house_qwen_001",
position=Vec3(x=qwen_x, y=0.0, z=0.0),
hp=60.0,
max_hp=60.0,
state="completed",
build_progress=10,
capacity=3,
),
]
def _seed_survival_relationships(npcs: list[Npc]) -> None:
for npc in npcs:
for other in npcs:
if other.id == npc.id:
continue
npc.relationships[other.id] = 0.45
def _assign_starting_homes(npcs: list[Npc], houses: list[House]) -> None:
if not houses:
return
for npc in npcs:
home = min(houses, key=lambda house: abs(house.position.x - npc.position.x))
npc.home_house_id = home.id
for house in houses:
citizens = [npc.id for npc in npcs if npc.home_house_id == house.id]
house.occupant_ids = citizens[: house.capacity]
def _initialize_importance(npcs: list[Npc]) -> None:
counts: dict[str, int] = {}
for npc in npcs:
role = normalize_role(npc.role)
counts[role] = counts.get(role, 0) + 1
for npc in npcs:
role_count = max(1, counts[normalize_role(npc.role)])
npc.importance = round(1.0 + 2.0 * (1 / role_count), 3)
def _spawn_qwen_npcs(config: GameConfig, *, count: int) -> list[Npc]:
half_width = config.world.width / 2
qwen_x = half_width * 2 / 3
offsets = [
(0.0, 0.0),
(BLOCK_SIZE, 0.0),
(-BLOCK_SIZE, 0.0),
(0.0, BLOCK_SIZE),
(BLOCK_SIZE, BLOCK_SIZE),
(-BLOCK_SIZE, BLOCK_SIZE),
]
npcs = []
inv_rng = random.Random(f"{config.world.seed}:qwen_inventory")
for i in range(count):
dx, dz = offsets[i % len(offsets)]
name = _QWEN_NAMES[i % len(_QWEN_NAMES)]
npc = Npc(
id=f"qwen-{i + 1:03d}",
name=name,
role="citizen",
position=Vec3(x=round(qwen_x + dx, 3), y=0.0, z=round(dz, 3)),
attack_damage=inv_rng.randint(5, 12),
memory=[MemoryEntry(tick=0, text=f"{name} joined Qwen as a free spirit.")],
age=25,
max_age=550,
unrestricted_actions=True,
connector_id="qwen",
country_id="qwen",
personality="curious and resourceful",
hunger=15.0,
safety=60.0,
inventory_food=inv_rng.randint(1, 3),
inventory_herbs=inv_rng.randint(0, 1),
)
npcs.append(npc)
return npcs
def _spawn_countries(config: GameConfig, npcs: list[Npc]) -> list[CountryState]:
if not config.world.survival:
return []
half_width = config.world.width / 2
nemotron_x = -(half_width * 2 / 3)
qwen_x = half_width * 2 / 3
citizens_by_country = {
"nemotron": [npc.id for npc in npcs if npc.country_id == "nemotron"],
"qwen": [npc.id for npc in npcs if npc.country_id == "qwen"],
}
return [
CountryState(
id="nemotron",
name="Nemotron",
color="#4f8cff",
badge="N",
citizen_ids=citizens_by_country["nemotron"],
treasury=TreasuryState(
id="treasury_nemotron",
position=Vec3(x=nemotron_x, y=0.0, z=BLOCK_SIZE * 1.5),
resources={"food": 2, "herbs": 1, "wood": 20, "coins": 25},
),
),
CountryState(
id="qwen",
name="Qwen",
color="#d86bff",
badge="Q",
citizen_ids=citizens_by_country["qwen"],
treasury=TreasuryState(
id="treasury_qwen",
position=Vec3(x=qwen_x, y=0.0, z=BLOCK_SIZE * 1.5),
resources={"food": 2, "herbs": 1, "wood": 20, "coins": 25},
),
),
]
def _assign_initial_rulers(world: WorldState) -> None:
for country in world.countries:
ruler = next((npc for npc in world.living_npcs() if npc.id in country.citizen_ids), None)
if ruler is None:
continue
country.ruler_id = ruler.id
ruler.special_status = "ruler"
def _random_max_age(seed: int, index: int) -> int:
return random.Random(f"{seed}:{index}:max_age").randint(320, 480)
def _random_block_center(rng: random.Random, *, half_width: float) -> float:
first_center = -half_width + (BLOCK_SIZE / 2)
num_blocks = round(half_width * 2 / BLOCK_SIZE)
return first_center + (rng.randrange(num_blocks) * BLOCK_SIZE)
def _random_world_position(
rng: random.Random,
*,
half_width: float,
half_depth: float,
) -> Vec3:
return Vec3(
x=round(rng.uniform(-half_width + BLOCK_SIZE, half_width - BLOCK_SIZE), 3),
y=0.0,
z=round(rng.uniform(-half_depth + BLOCK_SIZE, half_depth - BLOCK_SIZE), 3),
)