nodes/grid.pyΒΆ
Part of Tiny Yurts.
1"""Grid state: path graph, farms, yurts, and BFS pathfinder.
2
3The original Tiny Yurts uses a per-edge graph where each path tile is a
4single grid-edge between two adjacent cells (4-way + 4-diagonal). Settlers
5traverse the graph via BFS from yurt cell to any of the farm's cells.
6
7This mirrors that model. Paths are tracked as bidirectional edges between
8two cells; cells can be queried for neighbours via :meth:`Grid.neighbours`.
9"""
10
11from __future__ import annotations
12
13from collections import deque
14from dataclasses import dataclass, field
15
16from . import iso
17
18# 8-way adjacency offsets (matches original Tiny Yurts)
19ADJ8 = (
20 (1, 0),
21 (-1, 0),
22 (0, 1),
23 (0, -1),
24 (1, 1),
25 (1, -1),
26 (-1, 1),
27 (-1, -1),
28)
29
30
31@dataclass
32class Farm:
33 """An animal source. Tile size is 1x1 in this simplified port."""
34
35 kind: str # "ox" | "goat" | "fish"
36 cell: tuple[int, int]
37 demand: float = 0.0
38 capacity: float = 6.0 # max demand before unfed-loss triggers
39 colour: tuple[float, float, float, float] = (1.0, 1.0, 1.0, 1.0)
40
41
42@dataclass
43class Yurt:
44 """A consumer for one farm kind."""
45
46 kind: str
47 cell: tuple[int, int]
48 settlers: list[object] = field(default_factory=list)
49
50
51class Grid:
52 """Mutable grid state: path edges + entity placement + BFS routing."""
53
54 def __init__(self) -> None:
55 # Edges as unordered cell pairs frozenset of two tuples
56 self.edges: set[frozenset[tuple[int, int]]] = set()
57 # Adjacency map for fast lookup
58 self.adj: dict[tuple[int, int], set[tuple[int, int]]] = {}
59 self.farms: list[Farm] = []
60 self.yurts: list[Yurt] = []
61 # Cells that block path drawing (occupied by farm/yurt body) -- but
62 # they are still valid endpoints for paths.
63 self.blocked: set[tuple[int, int]] = set()
64
65 # ---------- Path edges ----------
66
67 def add_edge(self, a: tuple[int, int], b: tuple[int, int]) -> bool:
68 """Add a bidirectional edge between two adjacent cells."""
69 if not iso.in_bounds(*a) or not iso.in_bounds(*b):
70 return False
71 di, dj = b[0] - a[0], b[1] - a[1]
72 if (di, dj) not in ADJ8:
73 return False
74 key = frozenset((a, b))
75 if key in self.edges:
76 return False
77 self.edges.add(key)
78 self.adj.setdefault(a, set()).add(b)
79 self.adj.setdefault(b, set()).add(a)
80 return True
81
82 def remove_edge(self, a: tuple[int, int], b: tuple[int, int]) -> bool:
83 key = frozenset((a, b))
84 if key not in self.edges:
85 return False
86 self.edges.discard(key)
87 self.adj.get(a, set()).discard(b)
88 self.adj.get(b, set()).discard(a)
89 return True
90
91 def has_edge(self, a: tuple[int, int], b: tuple[int, int]) -> bool:
92 return frozenset((a, b)) in self.edges
93
94 @property
95 def path_count(self) -> int:
96 return len(self.edges)
97
98 # ---------- Entities ----------
99
100 def add_farm(self, farm: Farm) -> None:
101 self.farms.append(farm)
102 self.blocked.add(farm.cell)
103
104 def add_yurt(self, yurt: Yurt) -> None:
105 self.yurts.append(yurt)
106 self.blocked.add(yurt.cell)
107
108 # ---------- Pathfinding ----------
109
110 def neighbours(self, cell: tuple[int, int]):
111 return self.adj.get(cell, ())
112
113 def find_route(
114 self,
115 start: tuple[int, int],
116 targets: set[tuple[int, int]],
117 ) -> list[tuple[int, int]]:
118 """BFS along path edges. Returns shortest cell list start..goal,
119 or an empty list if unreachable.
120 """
121 if start in targets:
122 return [start]
123 if start not in self.adj:
124 return []
125 prev: dict[tuple[int, int], tuple[int, int] | None] = {start: None}
126 q: deque[tuple[int, int]] = deque([start])
127 while q:
128 n = q.popleft()
129 for nb in self.adj.get(n, ()):
130 if nb in prev:
131 continue
132 prev[nb] = n
133 if nb in targets:
134 # Reconstruct
135 path = [nb]
136 cur: tuple[int, int] | None = n
137 while cur is not None:
138 path.append(cur)
139 cur = prev[cur]
140 path.reverse()
141 return path
142 q.append(nb)
143 return []