Skip to content

Status: approved

demo-anno

Summary

apps/demo-anno is a playable Anno-style settlement builder on a small island grid: the player places a Lumberjack, Sawmill, Fisher, Warehouse, and Houses for gold, watches Wood, Planks, and Fish flow through production, carrying, and consumption, and grows the population to a goal before the treasury runs out (brief). This spec covers the six M1 features ANNO-F01 to ANNO-F06. The simulation is a fixed per-tick pipeline (produce, carry, consume, economy) on ecs-schedule, driven by a game clock with pause and speed. Goods are entities with an exclusive StoredIn relation, so a good is in exactly one place at a time. The demo adds no engine feature and uses only koota's public API. Save and resume (ANNO-F08), the second island (ANNO-F07), keyboard hints (ANNO-F09), the devtools panel (ANNO-F10), scenario selection (ANNO-F11), and everything marked later are out of scope here.

References

  • Brief: docs/products/demo-anno.md §3 (core loop), §4 (ECS showcase), §5 (backlog and notes), §6 (targets), §7 (seams), §8 (out of scope). concept §8. demo-rules (layout, actions, keyboard, content data).
  • ecs-schedule, by ID where used: SCH-002, SCH-003, SCH-004 (phase order and dependsOn), SCH-008, SCH-101, SCH-102 (schedule.phases), SCH-104 (rooted phases are automatic), SCH-010, SCH-011 (defineSystem, dt; F02), SCH-012, SCH-013 (same-phase order and after edges; first used in F03, again in F04, F05 and ANNO-109), SCH-022 (order depends only on the arrays), SCH-030, SCH-031 (run, dt unchanged, no clock), SCH-041 (invalid dt throws, mirrored by ANNO-024 in F02), SCH-065 (world.reset(), F06 test), SCH-143 (systemsOf, F06 test). SCH-140: the schedule name is passed to createSchedule (W-027 builds the minimal createSchedule({ name, phases, systems })) but no test asserts it before F06; the required-name check and schedule.name come with W-036, which only F06 needs (OQ17). The OnStart, enable/disable (SCH-054 included), conditions, sets, plugins, and hooks of the package are not used in M1 (see the SCH usage table).
  • koota 0.6.6 public API (read via the koota skill and reference/koota):
  • trait, relation({ exclusive: true }), createWorld, world.reset(), world traits through world.add, world.get, world.set.
  • Pairs: entity.add(StoredIn(place)) (replaces the old target), entity.targetFor(StoredIn), entity.targetsFor(StoredIn); .github/skills/koota/references/relations.md.
  • Queries: world.query, target filters StoredIn(place) and StoredIn('*'), Not, Or, createAdded, createRemoved, createChanged (.github/skills/koota/references/queries.md).
  • Tracking modifiers inside Or: koota tests cover Or of one modifier kind only (reference/koota/packages/core/tests/query-modifiers.test.ts: "should combine Or with Changed / Added / Removed modifiers…"). Mixing kinds inside one Or, and a destroyed entity appearing in Removed (stated only in the skill's queries.md, "includes destroyed entities"), have no koota test. No requirement depends on either (ANNO-083, ANNO-097, OQ16). updateEach flags Changed automatically for tracked traits (queries.md, "updateEach automatically detects changes").
  • Observers: world.onAdd, world.onRemove, world.onChange. world.reset() drops all observers (see demo-dnd DND-053), so setup registers them again.
  • World traits are singletons and never show up in world.query (reference/koota/packages/core/tests/world.test.ts, "should add, remove and get singletons"). world.onChange(WorldTrait, …) works (same file, "should observe traits").
  • Actions and React: createActions, useActions, useQuery, useTrait, WorldProvider.
  • flecs: Relationships.md (exclusive relationships, pairs with data), ObserversManual.md (observers), Systems.md § Pipelines (phases, custom phases, time and delta_time).

Requirements

All requirements are [test] or [manual] as tagged, and [ecs] (model and system behaviour, headless Vitest, fresh koota world per test) or [ux] (what the user sees or does, Testing Library with a real world in WorldProvider, or a manual check). Package behaviour is referenced by ID and never restated. The tick pipeline and the game clock are defined once in the ECS model below; the features reference them. Groups map to the M1 features and later to work items: F01 (ANNO-001..007, 009..016), F02 (020..036 and the re-homed 008, 040, 041, 043), F03 (042, 044..055), F04 (060..069), F05 (080..099), F06 (100..109). The gaps (017..019, 037..039, 056..059, 070..079, 110..119) are reserved for later requirements and are never reused by another group. IDs are never renumbered, so the four re-homed requirements keep their numbers although F02 needs them (goods, StoredIn, and stockOf before the first producer). Retired IDs are struck through and never reused. Every cross-reference points to the same or an earlier feature; where text names a later feature it says "from Fnn" and the behaviour exists only from then on.

Terms:

  • A building is an entity with Building, Name, Position, and Storage. It occupies exactly one cell (1×1). A producer also has Producer, a house has Housing and Settlers (from F04; before F04 it has only the common traits), a warehouse has the Warehouse tag. The island is the grid of width × height cells.
  • A good is an entity with Good { kind }, kind one of 'wood' | 'planks' | 'fish'. A place is a building (F02) or, from F03, a carrier. A carrier (from F03) is an entity with Carrier.
  • stockOf(world, place, kind) is the number of goods of kind stored in place (ANNO-043). totalStock(world, kind) is the number of goods of kind stored anywhere (in buildings, and from F03 in carriers).
  • distance(a, b) is |ax − bx| + |ay − by|.
  • A tick is one schedule.run(world, tickSeconds) (ECS model, Tick pipeline). dt is tickSeconds, in game seconds.
  • Status is Settlement.status: 'ongoing' | 'victory' | 'bankrupt' (shape in ANNO-001; only F06 changes it, ANNO-101).
  • Content values (costs, recipes, durations, grid size, goal, gold) come from src/content/ (ANNO-003). Numbers in the Content table are starting values, not requirements.
  • Order never depends on query iteration order: where a system or helper iterates entities it sorts by entity.id() ascending (ANNO-032).

ANNO-F01 Island grid, treasury, place and demolish buildings

SCH usage: none. Placing and demolishing are actions, no system runs in F01. F01 introduces the world traits listed in ANNO-001 (Treasury, Settlement with its full shape, Builder, Messages, and the empty GameClock and Stock). Their logic comes with later features: the clock and the pipeline with F02, Stock with F05. F01 has no goods (F02), no carriers (F03), and no Housing (F04), so a F01 building has only the common traits, Producer, or the Warehouse tag. F01 spawns buildings without Badge and placeBuilding and demolishAt do not call refreshStock: F05 extends the spawn function (ANNO-096) and both actions (ANNO-083). F01 to F04 views read statusOf directly; F05 switches the cells to Badge (ANNO-099).

  • ANNO-001 [ecs][test]: setupSettlement(world, content?) MUST add the world traits Treasury { gold = content.startGold, income = 0, cycleTimer = 0 }, Settlement { population = 0, goal = content.goal, status = 'ongoing' }, GameClock (ECS model), Builder, Stock, and Messages, and spawn every entry of content.startBuildings (default: one Warehouse) through the same spawn function as placeBuilding but without cost. Setup with no content argument MUST use the default content. In F01 and F02 the default Warehouse has no carriers; from F03 the spawn function adds them (ANNO-006, ANNO-044).
  • ANNO-002 [ecs][test]: The island MUST be at most 24×24. After setup every building MUST be inside it (0 ≤ x < width, 0 ≤ y < height) and no two buildings MUST share a cell. setupSettlement MUST throw, naming setupSettlement and the offending content entry, if content violates this.
  • ANNO-003 [ecs][test]: Costs, upkeep, recipes, durations, capacities, grid size, goal, and start gold MUST come only from content. A test MUST pass custom content (other numbers and names) to setupSettlement and assert that the spawned values and the placement costs equal it. Systems and actions MUST NOT contain such literals.
  • ANNO-004 [ecs][test]: placeBuilding(world, kind, x, y) MUST return true iff all hold: status is 'ongoing', kind is a content building kind, the cell is inside the island, no building occupies the cell, and Treasury.gold ≥ cost(kind). It MUST then spawn the building (ANNO-006) and subtract the cost with world.set(Treasury, …) (F05 adds a refreshStock call, ANNO-083). From F05, placing MUST also be allowed while the game is paused (ANNO-084); F01 has no pause.
  • ANNO-005 [ecs][test]: In every other case placeBuilding MUST return false and change nothing. One test each: unknown kind, out of bounds, occupied cell, gold = cost − 1 (and gold = cost succeeds, leaving 0), status not 'ongoing' (the test sets Settlement.status with world.set; F06 reaches it through ANNO-101).
  • ANNO-006 [ecs][test]: A spawned building MUST have Building { kind }, Name (the kind name plus a number, e.g. "Sawmill 2", unique among living buildings), Position, Storage { capacity } (content), and the kind's traits from content: Producer { recipe, progress: 0, active: false } for Lumberjack, Sawmill, and Fisher, the Warehouse tag for Warehouse. From F03 a Warehouse MUST also spawn its carriers (ANNO-044); in F01 and F02 it has none. From F04 a House MUST also have Housing and Settlers { count: 0 } (ANNO-060); before F04 it has only the common traits. F05 adds Badge to the spawn function (ANNO-096).
  • ANNO-007 [ecs][test]: demolishAt(world, x, y) MUST return true iff status is 'ongoing' and a building occupies the cell. It MUST destroy the building and free the cell, with no refund (OQ4). From F02 it also destroys the goods stored in the building (ANNO-008). From F03 it also resets every carrier whose to is that building (ANNO-048). F05 adds a refreshStock call (ANNO-083). Otherwise it MUST return false and change nothing.
  • ANNO-009 [ecs][test]: Place, demolish, and place again on the same cell MUST succeed, and the second building MUST be a new entity. Two buildings MUST never share a cell at any time.
  • ANNO-010 [ecs][test]: Builder { kind, x, y } MUST hold the selected building kind (or null) and the cursor cell. selectBuilding(world, kind | null), moveCursor(world, dir) ('up' | 'down' | 'left' | 'right', returns false and stays at the island edge), setCursor(world, x, y) (returns false outside the island), placeAtCursor(world), and demolishAtCursor(world) MUST be actions. placeAtCursor MUST equal placeBuilding at the cursor with the selected kind, and MUST return false without a selected kind. demolishAtCursor MUST equal demolishAt at the cursor.
  • ANNO-011 [ux][test]: The island MUST be a labelled region ("Island") of width × height cells. Each cell MUST have a text label with its coordinates and occupant: "3,2 Sawmill 1", "4,4 empty". From F02 a building's cell label also has its status text (ANNO-034). The cursor cell MUST have aria-current="true" and the text "cursor". Occupancy MUST NOT depend on colour alone (brief §6).
  • ANNO-012 [ux][test]: The game area MUST be a focusable container (tabIndex=0, label "Game") that holds the island, the build palette, and the HUD controls. Key rule (the only one; ANNO-091 and ANNO-106 refer to it): game keys MUST be handled by a keydown handler on the game area, not on window, and only when event.target is the game area element itself. A key pressed while focus is on a button, in any input, or in another region MUST NOT run any game-key handler and MUST NOT be preventDefaulted, so buttons keep their native Enter and Space activation and Tab keeps its normal focus order. The handler MUST ignore event.repeat for every key except the arrows, so a held arrow keeps moving the cursor and does not scroll the page. Keys of F01: arrows moveCursor (preventDefault), 1..5 selectBuilding (in the content order of the palette), Enter placeAtCursor, Delete or x demolishAtCursor, Escape selectBuilding(null). Later features add Space, [, ], R under the same rule. Focus rule (ANNO-013, ANNO-014, ANNO-093, ANNO-106 refer to it): the game area MUST have focus after mount and, from F06, after restart (ANNO-106), and every click or activation of a palette button, a cell, the "Demolish" button, or (from F05) a "Needs attention" item MUST move focus to the game area after its action (selectBuilding, setCursor, demolishAtCursor), so game keys work right after a click. From F05 the pause and speed buttons keep focus (ANNO-091). Test: click a cell, then document.activeElement is the game area and an ArrowRight keydown moves the cursor.
  • ANNO-013 [ux][test]: A "Build" group MUST have one button per content building kind showing its name and cost, with aria-pressed for the selected kind. Activating it MUST call selectBuilding and follow the focus rule of ANNO-012. A button whose cost exceeds Treasury.gold MUST be aria-disabled with the visible text "Need N gold" (N = the missing amount). It MUST be disabled when status is not 'ongoing'.
  • ANNO-014 [ux][test]: Clicking a cell MUST call setCursor for it, and MUST also call placeAtCursor when a kind is selected. A "Demolish" button MUST call demolishAtCursor and be aria-disabled when no building is on the cursor cell. Cell clicks and the button follow the focus rule of ANNO-012.
  • ANNO-015 [ux][test]: The text "Gold: N" (N = Treasury.gold) MUST be visible, and MUST update after every successful placement and demolition. From F05 the HUD of ANNO-080 reuses this element.
  • ANNO-016 [ux][manual]: A 24×24 island MUST fit a 1280×720 viewport without scrolling the page, with all cell labels, the palette, the Gold text, and (from F05) the HUD legible and WCAG AA contrast in both themes. Verify with the Acceptance checklist (A1).

ANNO-F02 Production cycles, goods, and StoredIn

SCH usage: SCH-003, SCH-004, SCH-008, SCH-101, SCH-102, SCH-104 (custom phases, ANNO-020), SCH-010 (defineSystem, ANNO-026), SCH-011 (ANNO-022), SCH-022 (order depends only on the arrays, ANNO-021), SCH-002 (phase order, ANNO-021), SCH-030 (ANNO-025), SCH-031 (dt, ANNO-022, ANNO-023), SCH-041 (invalid dt throws; ANNO-024 mirrors it). F02 has one system per phase, so same-phase order (SCH-012, SCH-013) first matters in F03; SCH-014 is not used by the demo. F02 builds the schedule and the clock (ECS model); later features add systems to it. SCH-140 is not used: name: 'anno/tick' is passed to createSchedule and no F02 test asserts it, so F02 needs W-027..W-029 only (OQ17). F02 does not read schedule.systems, systemsOf, or isEnabled.

The first four requirements were numbered in F01 or F03 and are re-homed here with unchanged IDs: the producer cycle (ANNO-027) needs goods, StoredIn, and stockOf. Carriers and houses do not exist yet.

  • ANNO-008 [ecs][test]: Demolishing a building MUST also destroy every good stored in it. When demolishAt returns, before any tick runs, no good MUST have a dangling StoredIn target (ANNO-040). Test: demolish a Sawmill that holds Wood and Planks, then run 20 ticks with runTicks without error. From F03 the same holds for the carriers of a Warehouse and the goods in them (ANNO-044) and for carriers whose to is the building (ANNO-048).
  • ANNO-040 [ecs][test]: Good { kind } MUST be a trait and StoredIn MUST be relation({ exclusive: true }). A good MUST be spawned with its StoredIn pair in the same world.spawn(Good({ kind }), StoredIn(place)). After setup, after every action, and after every tick, every Good entity MUST have exactly one StoredIn target (good.targetsFor(StoredIn).length === 1) and that target MUST be a living place: a building, and from F03 also a carrier. Test (F02): run 40 ticks of a Lumberjack, a Sawmill, and a Fisher with a demolish in the middle and check the invariant after each tick. The 400-tick chain with carriers is ANNO-055 (F03).
  • ANNO-041 [ecs][test]: A good MUST be moved only with good.add(StoredIn(newPlace)), never with an explicit remove first. After the call good.targetFor(StoredIn) MUST be newPlace, world.query(StoredIn(oldPlace)) MUST NOT contain it, and world.query(StoredIn(newPlace)) MUST (an exclusive relation replaces the old pair). Test (F02): spawn a good in building A and move it to building B.
  • ANNO-043 [ecs][test]: stockOf(world, place, kind) and totalStock(world, kind) MUST count goods with world.query(Good, StoredIn(place)) and world.query(Good, StoredIn('*')), filtered by Good.kind. They MUST read a good's place only through good.targetFor(StoredIn) and MUST NOT rely on relation targets in an updateEach or readEach tuple (koota drops relation filters from the tuple). Test (F02): place 2 Wood and 1 Fish in building A and 1 Wood in building B: stockOf(A, 'wood') = 2, stockOf(A, 'fish') = 1, stockOf(B, 'wood') = 1, totalStock('wood') = 3. Goods in carriers count in totalStock from F03 (ANNO-046).
  • ANNO-020 [ecs][test]: Pipeline (the one normative definition of schedule, phases, and order; the Tick pipeline table, ANNO-021, and ANNO-109 refer to it). The pipeline MUST be one schedule created with name: 'anno/tick' (passed only, not asserted, OQ17) and the custom phases Produce, Carry, Consume, Economy, defined by definePhase with dependsOn chained as Produce → [OnUpdate], Carry → [Produce], Consume → [Carry], Economy → [Consume] (SCH-003, SCH-004). schedule.phases MUST contain PreUpdate, OnUpdate, Produce, Carry, Consume, Economy, OnValidate, PostUpdate as a contiguous order of those eight entries (SCH-101, SCH-102, SCH-008), and schedule.isManual(phase) MUST be false for each of the four custom phases, so run executes them (SCH-104).
  • ANNO-021 [ecs][test]: Systems MUST run in the order of the ECS model table within one run, whatever their declaration order (SCH-002, SCH-022); same-phase order is tested in F03 (ANNO-046, ANNO-047). Behavioural test, with content that has no start buildings (so no carrier takes the Wood, F03 on, as in ANNO-028): run a Lumberjack to the end of its cycle with runTicks. After the finishing tick GameClock.tick MUST equal the number of ticks run and GameClock.elapsed that number times dt (clock/tick in PreUpdate), and one new Wood good MUST be StoredIn the Lumberjack (produce/cycle in Produce). The same-tick hand-over to a carrier (Produce before Carry) is tested in ANNO-046 (F03); the systems per phase are those of the Tick pipeline table and are checked by ANNO-109 in F06.
  • ANNO-022 [ecs][test]: Every system MUST be a pure function (world, dt) that takes all time from dt (SCH-011) and reads no wall clock (Date, performance, requestAnimationFrame) (SCH-031). The same sequence of placeBuilding and demolishAt calls followed by the same number of ticks MUST give the same world state and the same Messages in two fresh worlds (brief §7; the demo has no randomness).
  • ANNO-023 [ecs][test]: Game clock (the one normative definition; ANNO-084, ANNO-102, and the ECS model refer to it). GameClock MUST be a world trait { tick, elapsed, accumulator, speed, paused } (defaults 0, 0, 0, 1, false). advanceClock(world, realDt) MUST: do nothing and return 0 if paused or status is not 'ongoing'; otherwise add realDt × speed to accumulator, run schedule.run(world, content.tickSeconds) once for each full tickSeconds in accumulator (subtracting it), up to content.maxTicksPerAdvance per call, drop any time beyond that cap (leave accumulator < tickSeconds), and return the number of ticks run. Each tick's dt MUST be exactly tickSeconds (SCH-031), whatever realDt and speed are.
  • ANNO-024 [ecs][test]: advanceClock MUST throw if realDt is NaN, not finite, or negative, before it changes anything (as SCH-041 for the package). realDt of 0 MUST be accepted and run no tick.
  • ANNO-025 [ecs][test]: runTicks(world, n) MUST run n ticks of schedule.run(world, tickSeconds) without reading GameClock.paused, the accumulator, or any wall clock, so tests and replays drive the pipeline directly (brief §7). advanceClock and runTicks MUST give the same state for the same tick count (SCH-030).
  • ANNO-026 [ecs][test]: The system clock/tick (PreUpdate) MUST add 1 to GameClock.tick and dt to GameClock.elapsed, with world.set, once per tick, and nothing else. It is a defineSystem in PreUpdate (SCH-010).
  • ANNO-027 [ecs][test]: A producer cycle MUST work as follows, in the system produce/cycle (Produce), for each producer in entity-id order. If active is false and the cycle can start (ANNO-029, ANNO-030): destroy the recipe's input goods stored in the building, set active = true. While active: progress += dt (the start tick counts). When progress ≥ recipe.cycleSeconds: spawn recipe.output.amount goods of the output kind with StoredIn(building) in the same world.spawn (ANNO-040), and set active = false and progress = 0 (remaining time is dropped, so a cycle takes ceil(cycleSeconds / dt) ticks, OQ7). The next cycle can start on the next tick.
  • ANNO-028 [ecs][test]: Recipes MUST come from content (ANNO-003) and the default content MUST define: Lumberjack makes Wood from nothing, Sawmill makes Planks from Wood, Fisher makes Fish from nothing. With content that has no start buildings (so no carrier takes goods, F03 on), a test MUST run a Lumberjack alone for cycleSeconds / dt ticks and find exactly one Wood stored in it, and a Sawmill with one Wood stored in it for its cycle and find one Planks and no Wood.
  • ANNO-029 [ecs][test]: A producer that cannot start because an input good is missing, in amount recipe.inputs[i].amount, MUST get Idle { good } with the first missing input kind in recipe order. It MUST NOT consume anything and MUST NOT progress. A producer that has no inputs never gets Idle.
  • ANNO-030 [ecs][test]: A producer whose inputs are present but whose output kind is already stored at Storage.capacity in the building MUST get Blocked { good } (the output kind), and MUST NOT consume or progress. Idle takes precedence: if inputs are missing and the output is full, the producer gets Idle.
  • ANNO-031 [ecs][test]: Idle and Blocked MUST never be on a building at the same time. A producer that starts a cycle MUST have neither. Systems MUST add Idle, Blocked, or (from F04) Starved only when the building does not have it yet, and MUST remove it as soon as the condition no longer holds, so each transition is one add and one remove (from F05 the message observers of ANNO-088 rely on it). Changing the missing kind of an existing Idle MUST use set, not remove and add.
  • ANNO-032 [ecs][test]: Producers, carriers (from F03), and houses (from F04) MUST be processed in entity.id() ascending order in every system (not query order, which koota does not guarantee to be stable). Test: spawn buildings in a different order in two worlds with the same positions and content, run the same ticks, and compare the sorted state by name.
  • ANNO-033 [ecs][test]: statusOf(world, building) MUST be a pure read returning { kind, good?, progress? }: for a producer 'working' (with progress = Producer.progress / recipe.cycleSeconds, in [0, 1)) iff active, else 'idle' or 'blocked' (with good) from the tags, else 'waiting': the producer is not active and has neither tag, which holds from spawn until the first produce/cycle evaluates it, and for one tick after a completed cycle (ANNO-027: the next cycle starts on the next tick); 'none' for a warehouse. For a house the branch exists from F04 only: 'satisfied' iff Housing.satisfied, 'starved' (with good) iff it has Starved, else 'waiting': no consume cycle has run yet, so satisfied is false and there is no Starved (ANNO-062). Before F04 a house has no Housing and statusOf MUST return 'waiting' for it. Calling it MUST NOT change the world.
  • ANNO-034 [ux][test]: In F02 to F04 a building's cell label MUST show its status text computed by calling statusOf directly (no Badge, no economy/badges), and the cell MUST re-evaluate it every tick (it subscribes to GameClock with useTrait). The detail of the cursor building MUST show its statusOf as text, the same way. F05 (ANNO-096..099) switches the cell to Badge as a refactor: the observable text MUST stay unchanged and the tests of ANNO-011, ANNO-034, ANNO-051, and ANNO-067 MUST pass unchanged. Cell label shape is "<Name>: <status>": "Sawmill 1: working", "Sawmill 1: idle: no Wood", "Sawmill 1: blocked: Planks full", "Sawmill 1: waiting" (also "House 1: waiting" before F04). ANNO-051 is the one exception to exact text: from F03 it appends ", no carrier (build a Warehouse)" to a blocked status when no carrier exists. So the blocked assertion of this requirement MUST match the label as a prefix (Sawmill 1: blocked: Planks full, not anchored at the end). The test MUST reach Blocked with content that has no start buildings (so no carrier ever takes the output, as in ANNO-021 and ANNO-028; from F03 the suffix then appears, hence the prefix match) or set the state up directly (a producer with the output at capacity, and no ticks run). It passes in F02 and from F03 on with no change to the test; every other status assertion is exact. Detail adds the percentage: "working 60%". No state is conveyed by colour alone.
  • ANNO-035 [ux][test]: The detail of a producer under the cursor MUST show a role="progressbar" labelled "Progress" with aria-valuenow (0 to 100) from statusOf, updating every tick. It reads statusOf of the cursor building directly in every feature (never Badge), so the change-only badge updates of ANNO-097 (from F05) do not apply to it.
  • ANNO-036 [ux][manual]: A user who watches a Lumberjack, a Sawmill, and a Fisher for three cycles MUST be able to see progress advance, goods appear, and the reason when one is idle or blocked. Verify with the Acceptance checklist (A2).

ANNO-F03 Carriers and hauling, goods in exactly one place

SCH usage: SCH-013 (carry/assign after carry/move, ANNO-046, ANNO-047), SCH-012 (declaration order, ANNO-047), SCH-002 (Carry after Produce, ANNO-046), SCH-022 (determinism, ANNO-050). No new package feature. Goods, StoredIn, and stockOf come from F02 (ANNO-008, ANNO-040, ANNO-041, ANNO-043); houses accept nothing until F04.

  • ANNO-042 [ecs][test]: In koota 0.6.6 a move on an exclusive relation emits onRemove and then onAdd. Therefore no observer or Removed(StoredIn) query MUST be used to decide that a good "left" a place or that a place is "empty". Moving a good (pick-up, delivery) MUST NOT write a Messages entry. Test: with its own world.onAdd(Idle | Blocked | Starved) spies, run a scripted delivery of 10 goods into a Warehouse (capacity ≥ 10) in a world with no producer and no house, so no Idle, Blocked, or Starved add can happen during the script (assert the Warehouse's stockOf rose by 10, so the run is not vacuous) and assert that Messages.entries.length equals the number of Idle, Blocked, and Starved adds the spies counted. So no entry comes from a move. The count is 0 in F03 (no observer exists before F05) and the same test passes unchanged in F05, where ANNO-088 registers the observers.
  • ANNO-044 [ecs][test]: A Warehouse MUST spawn content.carriersPerWarehouse (default 2) carriers, each Carrier { to: null, remaining: 0 }, when it is spawned (ANNO-006). A carrier is idle iff to === null. Carriers MUST serve the whole island (no radius, OQ3). Demolishing the Warehouse MUST destroy its carriers and the goods stored in them (ANNO-008, ANNO-048).
  • ANNO-045 [ecs][test]: decideHaul(world, carrier) MUST be a pure read returning { good, to } | null. The carry systems MUST get hauling decisions only through this function (brief §7). Rules (nearest need):
  • A need is a pair (building B, kind K) where B is not a Warehouse, B accepts K (a producer whose recipe has input K, or, from F04, a house whose needs include K; before F04 a house accepts nothing), and stockOf(B, K) plus the goods of kind K currently carried to B is below Storage.capacity.
  • A source for K is a good of kind K that is stored in a building S ≠ B, where S is a Warehouse or a producer whose recipe output is K. Goods stored in a house, goods that are a producer's inputs, and goods stored in a carrier are never sources.
  • Choose the pair (need, source good) with the smallest distance(S, B). Ties: lowest B id, then lowest S id, then lowest good id.
  • If no pair exists, choose a storage haul: a source good of kind K in a producer S (K = the recipe output) and a Warehouse W with stockOf(W, K) plus the goods carried to W below Storage.capacity, with the smallest distance(S, W), ties as in 3.
  • Otherwise return null.
  • ANNO-046 [ecs][test]: The system carry/assign (Carry, after carry/move, SCH-013) MUST, for each idle carrier in entity-id order, call decideHaul and, if it returns a haul, do good.add(StoredIn(carrier)) and world.set(carrier, Carrier, { to, remaining: distance(source, to) × content.secondsPerCell }). It MUST evaluate carriers one after the other, so two carriers never pick the same good or overfill the same need. Goods teleport to the carrier on pick-up (no walking to the source, OQ3). A good stored in a carrier MUST count in totalStock and in no building's stockOf (ANNO-043). Test with default content: one tick in which a Lumberjack finishes its cycle MUST end with the new Wood already StoredIn a carrier (Produce before Carry, SCH-002), and totalStock('wood') counts it.
  • ANNO-047 [ecs][test]: The system carry/move (Carry, first: declared first, SCH-012, and before carry/assign, SCH-013) MUST, for each carrier with a to, subtract dt from remaining and, when it is ≤ 0, do good.add(StoredIn(to)) for the good stored in the carrier and set to = null. A carrier that delivers in a tick MUST be assignable again by carry/assign in the same tick. A delivery MUST NOT lose or duplicate a good (ANNO-040).
  • ANNO-048 [ecs][test]: demolishAt MUST, in the same call and before it returns, reset every carrier whose to is the demolished building: destroy the good stored in the carrier, set to = null and remaining = 0 (OQ4). Carriers of a demolished Warehouse are destroyed with it (ANNO-044), together with their goods. So carry/move never meets a dead to, no error MUST be thrown, and ANNO-040 MUST hold after the action and after every tick. Test: demolish a Sawmill while a carrier is on its way to it, assert at once that the carrier is idle and no good has a dangling target, then run 20 ticks.
  • ANNO-049 [ecs][test]: A delivery MUST NOT put more than Storage.capacity goods of one kind in a building. Test: 2 carriers and 1 Sawmill with capacity 4: over 200 ticks with a Lumberjack making Wood, stockOf(sawmill, 'wood') ≤ 4 after every tick.
  • ANNO-050 [ecs][test]: Carrier decisions MUST be deterministic and independent of query order (ANNO-032) and of declaration order (SCH-022). With no carrier in the world (no Warehouse), a producer's output MUST accumulate until it reaches Storage.capacity and the producer becomes Blocked (ANNO-030). That is the "nobody to carry" bottleneck.
  • ANNO-051 [ux][test]: When a building is Blocked and the world has no carrier, its status text MUST say "blocked: Wood full, no carrier (build a Warehouse)". With carriers present it MUST say only "blocked: Wood full" (ANNO-034). The view builds it from statusOf plus a check whether any Carrier exists, with no Badge (F05 keeps the text, ANNO-099).
  • ANNO-052 [ux][test]: A "Carriers" region MUST list every carrier as text: "Carrier 1: idle" or "Carrier 2: carrying Wood to Sawmill 1 (3 s)", updating every tick.
  • ANNO-053 [ux][test]: The detail of the cursor building MUST show its stock per kind as text ("Wood 2/5, Planks 0/5", for kinds it holds or accepts or produces) with the capacity, updating live.
  • ANNO-054 [ux][manual]: A user who builds Lumberjack, Sawmill, Warehouse and watches goods MUST be able to follow them: from the producer, via a carrier, to the Sawmill or the Warehouse. Verify with the Acceptance checklist (A3).
  • ANNO-055 [ecs][test]: ANNO-040 MUST hold with carriers: a good's StoredIn target may be a living carrier. Test (the scripted chain): run 400 ticks of a Lumberjack, a Sawmill, a Fisher, and the starting Warehouse with a demolish in the middle (once while a carrier is on its way to the demolished building) and check the invariant after each tick. From F04 Houses join the chain (ANNO-068, ANNO-096).

ANNO-F04 Houses consume goods, taxes, population

SCH usage: SCH-002, SCH-102 (the order Carry → Consume → Economy decides the same-tick behaviour of ANNO-065), SCH-013 (economy/stock after economy/settle, ANNO-066). The two new systems and the after edge are built with the systems of W-028, so F04 needs W-027, W-028, W-029. No new package feature.

  • ANNO-060 [ecs][test]: A House MUST have Housing { capacity, timer, satisfied } and Settlers { count } (a separate trait that changes only on growth, ANNO-063). capacity and the needs come from content (starting values: capacity 5 settlers; needs 1 Fish and 1 Planks per cycle of consumeSeconds). A new house MUST start with count: 0, timer: 0, satisfied: false.
  • ANNO-061 [ecs][test]: The system consume/houses (Consume) MUST, for each house in entity-id order, add dt to Housing.timer, and when timer ≥ consumeSeconds set timer = 0 and run one consume cycle: if the house has every need in the stock stored in it (stockOf), destroy the needed goods (one each) and set satisfied = true and remove Starved; otherwise set satisfied = false and add Starved { good } with the first missing kind in content order (if it does not have Starved yet; if it has it, set the kind).
  • ANNO-062 [ecs][test]: satisfied MUST be unchanged between consume cycles, so a house that is satisfied stays satisfied until its next failed cycle. Until its first consume cycle a house is waiting: satisfied is false and it has no Starved (ANNO-033). Needs MUST be the only goods a house takes in: decideHaul never delivers another kind to a house (ANNO-045).
  • ANNO-063 [ecs][test]: The system economy/settle (Economy, first) MUST add dt to Treasury.cycleTimer and, when it reaches content.economyCycleSeconds, set it to 0 and run one economy cycle: for each house in entity-id order, if satisfied: taxes += count × taxPerSettler (the count before this cycle's growth), then count = min(capacity, count + content.growthPerCycle); if not satisfied: no taxes and no growth (the house stalls). upkeep is the sum of the content upkeep of all buildings. Then Treasury.gold += taxes − upkeep and Treasury.income = taxes − upkeep, both with one world.set. Gold MAY become negative (the game ends on it from F06, ANNO-101).
  • ANNO-064 [ecs][test]: Population MUST NOT shrink in M1 (OQ6). A satisfied house that misses a consume cycle becomes unsatisfied and stalls, pays no taxes, and becomes satisfied again at the first later consume cycle that finds its needs. A house with count = capacity MUST still pay taxes when satisfied and MUST NOT exceed capacity.
  • ANNO-065 [ecs][test]: Phase order MUST give this same-tick result (SCH-002, SCH-102): a good produced in Produce is picked up in Carry, and a good delivered in Carry to a house is consumable by consume/houses in Consume of the same tick. Test with a House that holds one Planks and has a carrier that delivers the Fish on this tick: the consume cycle that falls on this tick finds both.
  • ANNO-066 [ecs][test]: Settlement.population MUST equal the sum of Settlers.count of all houses. It is recomputed unconditionally (a sum over all houses, no change detection) by refreshStock(world), introduced here with population only, which the system economy/stock (Economy, after economy/settle, SCH-013) calls once per tick. F05 extends refreshStock with Stock (ANNO-081) and makes placeBuilding and demolishAt call it (ANNO-083). It MUST write with world.set only when the value differs. No Added, Changed, or Removed query is used for population (OQ16). Test: after a quiet tick world.onChange(Settlement, spy) MUST NOT have been called; after a growth it MUST be called once. In F04 demolishing a house lowers population at the next tick; at once, without a tick, is ANNO-083 (F05).
  • ANNO-067 [ux][test]: A house's cell label and cursor detail MUST show its status and settlers as text: "House 1: 3/5 settlers, satisfied", "House 2: 0/5 settlers, stalled: no Fish", "House 3: 0/5 settlers, waiting" (ANNO-033). In F04 the cell reads statusOf and Settlers directly (ANNO-034). After F05 it reads the status from Badge and also subscribes to Settlers with useTrait(house, Settlers), because Badge has no settler count (carve-out in ANNO-099).
  • ANNO-068 [ecs][test]: Scenario with default content: a Lumberjack, a Sawmill, a Fisher, the starting Warehouse, and two Houses, placed by placeBuilding, MUST reach Settlement.population ≥ 5 within 2000 ticks and have no Starved house at the end, and the same island without the Fisher MUST have Starved { good: 'fish' } on every house and population = 0. ANNO-040 MUST hold after every tick of both runs. If content tuning breaks it, the content changes, not this requirement.
  • ANNO-069 [ux][manual]: A user who removes the Fisher MUST see the houses stall (status text, and from F05 the message) and, from F05, the income drop. Verify with the Acceptance checklist (A4).

ANNO-F05 HUD, pause and speed, messages

SCH usage: SCH-030 (ANNO-085), SCH-031 (speed changes how many ticks run per call, never dt, ANNO-086), SCH-013 (economy/badges after economy/stock, ANNO-097). No other package feature: pause is not enable/disable, and ANNO-085 is tested through the clock without isEnabled or schedule.systems, so F05 needs W-027..W-029 only (no W-031, no W-036).

  • ANNO-080 [ux][test]: A "Settlement" region MUST show as text: "Gold: N" (ANNO-015), "Income: +N per cycle" (signed, from Treasury.income), "Population: N" (Settlement.population), and the stock of each content good as "Wood: N", "Planks: N", "Fish: N" (Stock). All values MUST update live after every tick that changes them. The test MUST match the population text with the regex /Population: (\d+)/, not anchored at the end, so ANNO-107 (F06), which appends " / goal", passes it unchanged.
  • ANNO-081 [ecs][test]: Stock MUST be a world trait { counts: Record<GoodKind, number> }. refreshStock (ANNO-083, called by the system economy/stock) MUST compute totalStock per kind unconditionally and write Stock with world.set and a new counts object only if at least one count differs, so useTrait(Stock) and world.onChange(Stock, spy) fire only on real changes. Test: a tick with no change does not call the spy; a tick where a producer completes a cycle calls it once.
  • ANNO-082 [ecs][test]: Stock MUST NOT be derived from change detection on StoredIn: koota cannot narrow Changed(StoredIn) to a target, and it fires only on set() or changed(), not on add or remove. Test: a delivery that moves a good from a Warehouse to a carrier (totalStock unchanged) MUST NOT call the spy of ANNO-081.
  • ANNO-083 [ecs][test]: refreshStock(world) MUST recompute, unconditionally and in this order, Settlement.population (ANNO-066) and then Stock (ANNO-081), each written only if it differs. It is the whole body of the system economy/stock. F05 extends placeBuilding (ANNO-004) and demolishAt (ANNO-007): each MUST call refreshStock after a successful change, so the HUD is right while the game is paused (F01 to F04 do not call it there). It MUST NOT use tracking queries (OQ16). Test: pause, demolish a building that holds Wood, and world.get(Stock) changes without a tick; pause, demolish a House with settlers, and Settlement.population drops without a tick.
  • ANNO-084 [ecs][test]: setPaused(world, paused) MUST set GameClock.paused and return true, or return false and change nothing when status is not 'ongoing'. While paused, advanceClock behaves as defined in ANNO-023 (no tick, accumulator unchanged), so game time stands still and no system runs. placeBuilding, demolishAt, selectBuilding, and moveCursor MUST keep working while paused.
  • ANNO-085 [ecs][test]: Pause MUST NOT use schedule.disable or enable. Pause and speed are clock properties, not per-system toggles: they stop or scale all four phases at once, they must persist as world data with the clock (ANNO-F08 later), and schedule.state.disabled is reserved for explicit toggles such as devtools or tests (ecs-schedule summary). Test: after setPaused(true) and setPaused(false), runTicks(world, 1) MUST still advance GameClock.tick by 1 (clock/tick is enabled; schedule.run, SCH-030), and a direct runTicks(world, 1) while paused MUST advance it too (ANNO-025). The test MUST NOT read schedule.systems, systemsOf, or isEnabled.
  • ANNO-086 [ecs][test]: setSpeed(world, speed) MUST set GameClock.speed and return true iff speed is in content.speeds (default 1, 2, 4) and status is 'ongoing', otherwise false and change nothing. Speed only multiplies the accumulated time (ANNO-023, SCH-031): the final state after the same number of ticks MUST be identical at every speed. Test: advanceClock for 8 s at speed 1 and for 2 s at speed 4 (with tickSeconds = 0.25, maxTicksPerAdvance ≥ 32) both run 32 ticks and end in the same world state.
  • ANNO-087 [ecs][test]: Messages MUST be an AoS world trait { entries: Message[] }, each entry { id, tick, kind, text } with kind one of 'idle' | 'blocked' | 'starved', ids strictly increasing, oldest first. The list MUST keep at most content.maxMessages (default 50) newest entries. Appending MUST use world.set with a new array.
  • ANNO-088 [ecs][test]: Setup MUST register world.onAdd(Idle, …), world.onAdd(Blocked, …), and world.onAdd(Starved, …) (observers, in koota 0.6.6) that append one entry each: "Sawmill 1 has no Wood", "Sawmill 1: Planks full", "House 2 has no Fish", with the current GameClock.tick. A condition that persists for many ticks MUST log exactly one entry (ANNO-031). A recovery followed by the same condition again MUST log a second one. Removing a tag or demolishing the building MUST NOT log. Moving a good (pick-up, delivery) MUST NOT log (ANNO-042): after a scripted delivery of 10 goods, Messages.entries.length equals the number of Idle, Blocked, and Starved adds counted by the test's own spies (the test of ANNO-042). Observing onAdd only is deliberate: there is no onRemove or onChange observer, because messages announce a problem starting, never a recovery, a change of the missing kind, or a demolition (ANNO-042).
  • ANNO-089 [ecs][test]: Setup MUST register each observer exactly once: after setup a new Idle logs exactly one entry. From F06, restartSettlement (ANNO-105) MUST NOT leave observers of the old game behind or lose them (world.reset() drops all observers): after a restart a new Idle logs exactly one entry.
  • ANNO-090 [ecs][test]: Observer callbacks run synchronously inside the add. They MUST write only the Messages world trait and MUST NOT mutate the observed entity or any other building trait, so the add that triggered them cannot re-enter. Test: the building's traits are identical before and after the observer ran, and no Idle, Blocked, or Starved add triggers a second add.
  • ANNO-091 [ux][test]: A "Pause" toggle button (aria-pressed, text "Pause" or "Paused") and a "Speed" group of buttons "1×", "2×", "4×" (aria-pressed, from content.speeds) MUST call setPaused and setSpeed. Under the key rule of ANNO-012: Space MUST toggle pause (preventDefault), [ MUST pick the next lower and ] the next higher speed. While paused the text "Paused" MUST be visible. Clicking the Pause button or a Speed button MUST leave focus on that button (the focus rule of ANNO-012 does not apply to them). Test: click each, then document.activeElement is that button.
  • ANNO-092 [ux][test]: The messages MUST be a role="log" region labelled "Messages" with aria-live="polite", listing the text of every entry newest first, updating live.
  • ANNO-093 [ux][test]: A "Needs attention" region MUST list every building that currently has Idle, Blocked, or Starved as text with its reason (the status text of ANNO-034), live (useQuery), and say "None" when there is none. Moving the cursor to a listed building from the list MUST be possible by keyboard (each item is a button that calls setCursor and follows the focus rule of ANNO-012).
  • ANNO-094 [ux][test]: useGameLoop(world) MUST call advanceClock once per animation frame with the real elapsed seconds since the previous frame, capped at 0.1, and MUST stop when the component unmounts. The first frame MUST call it with 0. Test with a fake requestAnimationFrame.
  • ANNO-095 [ux][manual]: With 40 buildings and 500 goods on a 24×24 island at the fastest speed, the UI MUST stay at 60 FPS, and one tick of logic MUST take under 16 ms (brief §6). Verify with the Acceptance checklist (A5: FPS in steps 1 to 3, tick time in step 4).

  • ANNO-096 [ecs][test]: Every building MUST have the entity trait Badge { kind, good } (SoA, good is '' when none): a cache of statusOf without progress, for display (ANNO-099). F05 extends the spawn function of ANNO-006, which MUST set it from the initial status ('waiting' for a producer or house, 'none' for a warehouse). Invariant: after every tick and every action, for every building Badge equals statusOf minus progress. Test: run the scripted chain of ANNO-055 with two Houses added and check it after each tick.

  • ANNO-097 [ecs][test]: The system economy/badges (Economy, after economy/stock, SCH-013) MUST recompute Badge only for the buildings in the union of three queries created once at module scope and read by this system only: Or(Changed(Producer), Changed(Housing), Changed(Idle), Changed(Blocked), Changed(Starved)), Or(Added(Idle), Added(Blocked), Added(Starved)), and Or(Removed(Idle), Removed(Blocked), Removed(Starved)). Each Or holds one modifier kind from one createChanged/createAdded/createRemoved instance, the shape koota tests (References). It MUST process the union in entity.id() order, skip entities for which entity.isAlive() is false (demolition needs no badge update), and write Badge with world.set only if kind or good differs. Systems write Producer, Housing, and the tags only through updateEach, set, add, and remove, so tracking sees every change. This is the Changed-driven status update of brief §4.
  • ANNO-098 [ecs][test]: Badge MUST be written only on a status change, not on every tick. With world.onChange(Badge, spy): a tick with no status change does not call the spy; a Sawmill that stays idle for 20 ticks causes no call after the one for its first Idle; a Lumberjack over 3 cycles causes at most 2 calls per cycle (working to waiting at the end of a cycle, waiting to working at the start of the next, ANNO-033), not one per tick. Changed(Producer) and Changed(Housing) match every working producer and every house each tick (their progress and timer change every tick), so the queries narrow the recompute set but the write-if-differs rule is what makes updates change-only (OQ15).
  • ANNO-099 [ux][test]: This is the F05 refactor of the cell from statusOf (ANNO-034) to Badge, with unchanged observable text (ANNO-011, ANNO-034, ANNO-051, ANNO-067 tests pass unchanged). A cell MUST read its status with useTrait(building, Badge) and no longer subscribe to GameClock. A cell whose Badge did not change MUST NOT re-render on a tick, with two carve-outs: a house cell also subscribes to Settlers and re-renders when Settlers changes (ANNO-067), and a cell with a Blocked badge re-renders when the existence of any Carrier changes (ANNO-051). Test: render the island with a render counter per cell, run 20 ticks with one idle Sawmill and a working Lumberjack, and assert that the Sawmill cell renders at most once after its first Idle and the Lumberjack cell at most twice per cycle.

ANNO-F06 Goal, victory, bankruptcy, restart

SCH usage: SCH-065 (restart through world.reset(): schedule state back to defaults, test only), SCH-143 (systemsOf, final check in ANNO-109), SCH-013 (after edges within a phase, cited by ANNO-109; already built for F03), SCH-002 (PostUpdate after Economy). The settlement/outcome system is the one in PostUpdate.

  • ANNO-100 [ecs][test]: The shape of Settlement and its setup values are defined in ANNO-001; this feature adds only the outcome. status MUST be written only by settlement/outcome (ANNO-101) and by restart (ANNO-105). Test (bankrupt stays bankrupt, the stickiness of ANNO-101): reach 'bankrupt' with the content of ANNO-103, then with world.set set Treasury.gold to 1000 and Settlement.goal to 0 (population is recomputed by economy/stock, so the goal is what makes population ≥ goal hold at the outcome check). Assert both conditions hold (gold ≥ 0, population ≥ goal, so the system would set 'victory' if status were 'ongoing'), run 20 runTicks, and assert status is still 'bankrupt'.
  • ANNO-101 [ecs][test]: The system settlement/outcome (PostUpdate, after the custom phases, SCH-002) MUST set status = 'victory' when population ≥ goal, else 'bankrupt' when Treasury.gold < 0, else leave it. When both hold, victory wins. It MUST do nothing when status is not 'ongoing', so a status other than 'ongoing' stays as it is until restart, whatever gold and population do afterwards (tested by ANNO-100).
  • ANNO-102 [ecs][test]: Gating on status is defined where each action is, and this requirement only checks it end to end: advanceClock (ANNO-023), placeBuilding and placeAtCursor (ANNO-004, ANNO-005, ANNO-010), demolishAt and demolishAtCursor (ANNO-007, ANNO-010), setPaused (ANNO-084), setSpeed (ANNO-086). Test: after 'victory' and after 'bankrupt', one call of each returns false (0 for advanceClock) and the world is unchanged. runTicks (ANNO-025) is for tests only and is not gated.
  • ANNO-103 [ecs][test]: Bankruptcy MUST be reachable: with custom content (startGold 3, a Warehouse upkeep above 3 per economy cycle, no houses) the game MUST reach 'bankrupt' at the first economy cycle that leaves gold < 0, and Treasury.income MUST be negative.
  • ANNO-104 [ecs][test]: Victory MUST be reachable: with custom content (goal: 1, one satisfied house with growth) the game MUST reach 'victory' on the tick of the economy cycle that makes population ≥ goal.
  • ANNO-105 [ecs][test]: restartSettlement(world) MUST call world.reset() and then setupSettlement with the same content as the original call, remembered per world outside the ECS traits (e.g. a WeakMap in actions.ts), because world.reset() removes every trait. Afterwards: all old entity handles are dead, status is 'ongoing', gold is startGold, GameClock is { 0, 0, 0, 1, false }, Messages is empty, the starting buildings and their carriers exist, the schedule state is back to defaults (SCH-065), and the observers of ANNO-088 are registered once (ANNO-089). A test MUST run a scripted game, restart, replay the same inputs, and assert the same final state and the same Messages kind and text sequence.
  • ANNO-106 [ux][test]: When status is not 'ongoing' the UI MUST show a role="status" banner with the text "Victory" or "Bankrupt" and a "Restart" button. Activating it (or R on the game area, key rule of ANNO-012) MUST call restartSettlement and move focus to the game area, and the UI MUST show the new settlement (start gold, no stale buildings, empty messages). When the status ends while a button in the palette or the HUD has focus and becomes disabled (ANNO-013), focus MUST move to the game area so the keyboard user can press R.
  • ANNO-107 [ux][test]: The HUD MUST show the goal as text, "Population: N / goal" (extends ANNO-080, whose regex test still passes), updating live.
  • ANNO-108 [ux][manual]: A player MUST be able to reach victory in a 10 to 15 minute session at speed 1, and to reach bankruptcy by building without income, then restart in both cases. Verify with the Acceptance checklist (A6).
  • ANNO-109 [ecs][test]: For every phase, schedule.systemsOf(phase) (SCH-143) MUST return exactly the systems the Tick pipeline table (ECS model) lists for that phase, in the table's order, and phases not in the table MUST return []. Order within a phase comes from after edges (SCH-013). This is the only requirement that reads systemsOf (it needs W-036, OQ9); earlier features test order behaviourally (ANNO-021).

ECS model

The model follows demo-rules: content in src/content/, ECS only in src/ecs/, views in src/ui/<feature>/.

Content (src/content/)

Plain typed data, no logic. Starting values only (tuning, not requirements):

Building Cost Upkeep per economy cycle Recipe (inputs → output, cycle) Per-kind capacity Extra
Lumberjack 20 1 → 1 Wood, 4 s 5
Sawmill 40 2 1 Wood → 1 Planks, 6 s 4
Fisher 30 1 → 1 Fish, 5 s 5
Warehouse 60 2 20 carriersPerWarehouse 2
House 25 0 needs 1 Fish + 1 Planks per consumeSeconds 10 2 5 settlers, taxPerSettler 1, growthPerCycle 1

settlement.ts (island size 16×16, startGold 300, goal 50, startBuildings: one Warehouse at the middle), clock.ts (tickSeconds 0.25, speeds [1, 2, 4], maxTicksPerAdvance 20, secondsPerCell 0.5, economyCycleSeconds 10, maxMessages 50).

Traits (src/ecs/traits.ts)

Trait Kind From Shape
Name SoA F01 { value: string }
Position SoA F01 { x, y }
Building SoA F01 { kind }
Storage SoA F01 { capacity } (per goods kind)
Warehouse tag F01
Producer SoA F01 (spawned), F02 (run) { recipe, progress, active } (recipe is a content id)
Good SoA F02 { kind } (ANNO-040)
Idle, Blocked SoA F02 { good } (the missing or full kind; add and remove are the events)
Carrier SoA F03 { to: Entity \| null, remaining }
Housing SoA F04 { capacity, timer, satisfied }
Settlers SoA F04 { count }
Starved SoA F04 { good } (the missing kind)
Badge SoA F05 { kind, good } (cached statusOf without progress, ANNO-096)

Relations (src/ecs/relations.ts)

  • StoredIn (from F02, ANNO-040): relation({ exclusive: true }), source = a good, target = a place (a building, and from F03 a carrier). No data.

World traits (singletons, added by setup)

Trait Shape
Treasury { gold, income, cycleTimer }
GameClock { tick, elapsed, accumulator, speed, paused }
Settlement { population, goal, status }
Stock AoS { counts }, replaced only on change (ANNO-081)
Builder { kind, x, y } (cursor and selected kind)
Messages AoS { entries }, replaced on every append (ANNO-087)

World traits are not queryable, so change detection on them uses world.onChange and useTrait, not Changed (koota constraint 3).

Tick pipeline (defined once)

The schedule, its phases, and their order are defined in ANNO-020 only, and one tick is schedule.run(world, tickSeconds) (ANNO-023). This table is the single list of systems with their phase and order; ANNO-109 checks it.

Phase Order System Introduced in Does
PreUpdate 1 clock/tick F02 tick count and elapsed time (ANNO-026)
Produce 1 produce/cycle F02 start cycles, progress, output goods, Idle or Blocked (ANNO-027..031)
Carry 1 carry/move F03 advance carriers, deliver goods (ANNO-047)
Carry 2 carry/assign F03 idle carriers pick up goods through decideHaul (ANNO-046)
Consume 1 consume/houses F04 house timers, consume cycle, Starved (ANNO-061)
Economy 1 economy/settle F04 economy cycle: taxes, growth, upkeep (ANNO-063)
Economy 2 economy/stock F04, F05 refreshStock: population (ANNO-066, F04), then Stock (ANNO-081, ANNO-083, F05)
Economy 3 economy/badges F05 change-driven Badge update (ANNO-097)
PostUpdate 1 settlement/outcome F06 victory or bankruptcy (ANNO-101)

The final order is checked by ANNO-109 (F06). OnStart, sets, plugins, hooks, runIf, and enable/disable are not used in M1.

Game clock (defined once)

GameClock and advanceClock(world, realDt) are defined in ANNO-023 only; they are the only place where real time becomes game time. The schedule never reads a clock (SCH-031). The accumulator, speed, and paused live in the world, so they are saved with it later. useGameLoop (ANNO-094) calls advanceClock from requestAnimationFrame; tests and replays call runTicks (ANNO-025). Pause and speed act only on advanceClock:

Concern Mechanism Not
Pause GameClock.paused gates advanceClock (ANNO-023, ANNO-084) schedule.disable, runIf (ANNO-085, OQ2)
Speed multiplies accumulated time, dt stays tickSeconds (ANNO-023, ANNO-086) changing dt
End of game Settlement.status gates advanceClock (ANNO-023, ANNO-102)

Actions (src/ecs/actions.ts, createActions)

setupSettlement, restartSettlement, placeBuilding, demolishAt, selectBuilding, moveCursor, setCursor, placeAtCursor, demolishAtCursor, setPaused, setSpeed, advanceClock, runTicks, refreshStock. Pure helpers: stockOf, totalStock (F02), decideHaul (F03), statusOf (F02). Views never mutate traits; they call actions.

Views (src/ui/)

Folder Content Requirements
island/ grid, cell labels, cursor, click ANNO-011, ANNO-014, ANNO-099
build/ palette, demolish ANNO-013, ANNO-014
detail/ status, progress, stock of the cursor building ANNO-034, ANNO-035, ANNO-053, ANNO-067
carriers/ carrier list ANNO-052
hud/ settlement HUD, pause and speed, needs attention, messages ANNO-015, ANNO-080, ANNO-091, ANNO-092, ANNO-093, ANNO-107
outcome/ victory or bankrupt banner, restart ANNO-106
input/ key handling on the game area, useGameLoop ANNO-012, ANNO-094

Keys (only when the game area itself has focus, ANNO-012): arrows cursor, 1..5 select building, Enter place, Delete or x demolish, Escape deselect, Space pause, [ and ] speed, R restart (when over). d stays the template's theme toggle. On-screen hints are ANNO-F09.

koota 0.6.6 constraints (S1 result, W-070)

The four APIs the brief relies on are in the public API. Their limits shape the requirements:

API Limit Requirements
Exclusive relation relation({ exclusive: true }) a move emits onRemove then onAdd: no "empty" message on onRemove without filtering ANNO-040, ANNO-041, ANNO-042
Relation target filters StoredIn(target), StoredIn('*') updateEach and readEach drop relation filters from the tuple: read targets with entity.targetFor ANNO-043
Changed(Trait) via createChanged() at module scope cannot narrow Changed(relation) to a target, fires only on set() and changed(), not on add or remove; world traits are not queryable ANNO-081, ANNO-082, ANNO-097, ANNO-098
Observers world.onAdd, onRemove, onChange synchronous: callbacks must not mutate the observed entity; world.reset() drops them ANNO-088, ANNO-089, ANNO-090

ecs-schedule usage per feature

Feature SCH requirements used Work items it needs (done before it starts) Notes
ANNO-F01 none none actions only
ANNO-F02 SCH-003, SCH-004, SCH-008, SCH-101, SCH-102, SCH-104, SCH-010, SCH-011, SCH-022, SCH-002, SCH-030, SCH-031, SCH-041 W-027, W-028, W-029 first systems and the clock; SCH-012, SCH-013 from F03, SCH-014 unused; goods, StoredIn, stockOf (re-homed ANNO-008, 040, 041, 043); name passed to createSchedule, SCH-140 not asserted (OQ17)
ANNO-F03 SCH-013, SCH-012, SCH-002, SCH-022 W-027, W-028, W-029 no new package feature
ANNO-F04 SCH-002, SCH-102, SCH-013 W-027, W-028, W-029 phase order (ANNO-065) and the economy/stock after economy/settle edge (ANNO-066)
ANNO-F05 SCH-030, SCH-031, SCH-013 W-027, W-028, W-029 pause is not enable/disable, tested through the clock (ANNO-085); economy/badges after economy/stock (ANNO-097); no W-031, no W-036
ANNO-F06 SCH-065 (test), SCH-143 (test), SCH-013 (ANNO-109), SCH-002 (ANNO-101) W-033, W-036 (tests only) W-036 (SCH-140..143) pulls W-033 and W-035 and so W-034, W-032, W-031

W-030 (OnStart, errors, re-entrancy), W-031 (enable/disable), W-032 (conditions), W-034 (sets), W-035 (plugins), and W-037 (hooks) are not used by any M1 behaviour or test. W-030, W-031, W-032, and W-034 are only prerequisites of W-033, W-035, and W-036 in the package chain, which only F06 needs.

Differences from flecs

  • StoredIn is a koota exclusive relation, flecs's Exclusive relationship trait. A move is an add that replaces the old pair, and koota reports it as a remove and an add (ANNO-042).
  • Relation target filters (StoredIn(building), StoredIn('*')) stand in for flecs wildcard and fixed-target pair queries. Per-target change detection does not exist in koota (ANNO-082).
  • The pipeline uses flecs's custom phases but is stepped by a game clock with a fixed tick, not by ecs_progress with delta_time and a time scale. Pause and speed live in the clock (ANNO-085), the fixed step makes runs replayable (ANNO-022, ANNO-025).
  • Observers are koota's world.onAdd, not flecs observers with events and iterators. They are synchronous and may not mutate the observed entity (ANNO-090).
  • Singletons are world traits (Treasury, GameClock, …), not flecs singleton components. They are not queryable.
  • Changed is a koota tracking modifier owned by one system and reset on each read of that query, not flecs change detection on tables (ANNO-097). The demo uses it only for the status Badge (ANNO-097, ANNO-098); stock counters and population use unconditional recompute plus write-if-differs on world traits, which are not queryable (ANNO-081, ANNO-083, OQ15). Because progress and timer change every tick, Changed(Producer) and Changed(Housing) narrow the recompute set only to working producers and houses; the change-only guarantee is on the Badge writes and renders.

Out of scope

  • Everything in brief §8: roads, pathfinding, coverage radii, terrain, settler tiers and upgrades, trade, random events, ships and a second island (ANNO-F07), more goods than Wood, Planks, and Fish, combat, 3D, animation polish.
  • ANNO-F08 (save and resume, ecs-serialize), ANNO-F09 (on-screen key hints and polish beyond the keys listed), ANNO-F10 (devtools panel), ANNO-F11 (scenario selection), ANNO-F12..F14.
  • Building footprints larger than one cell, rotation, refunds on demolish, undo, and moving buildings.
  • Carriers with positions that walk to sources, and any carrier choice other than decideHaul rules 1 to 5.
  • Population shrinking, emigration, and happiness.
  • Any change to packages/ecs-*. A missing package feature becomes a package work item.

Open questions

Each has a recommended default. The requirements above already use the defaults. If the user decides otherwise, the named requirements change.

  1. Upkeep and the bankruptcy trigger. The brief mentions income and "the treasury runs out" but no costs after placement. Default: every building has an upkeep per economy cycle, bankruptcy when gold < 0 after a cycle (ANNO-063, ANNO-101). Alternative: no upkeep and bankrupt when gold is 0, nothing can be afforded, and no income exists (a stuck state, harder to detect).
  2. Pause mechanism. Default: clock-level, GameClock.paused stops advanceClock (ANNO-084, ANNO-085). This keeps ANNO-F05 off the schedule's enable/disable (no W-031 needed). Alternatives: (a) schedule.disable of every system (pollutes schedule.state.disabled, speed not expressible), (b) a shared runIf that returns false while paused (needs W-032, and still needs the clock gate so paused real time does not pile up as ticks). If the user wants a direct schedule.run to respect pause, choose (b).
  3. Carriers. Default: a pool of carriers per Warehouse, serving the whole island, goods teleport on pick-up, travel time is distance × secondsPerCell (ANNO-044..047). Alternatives: carriers with positions that walk to the source first, or a service radius per Warehouse. Both are closer to ANNO-F14 and out of M1.
  4. Demolish. Default: no refund; goods stored in the building, in its carriers, or on the way to it are destroyed (ANNO-007, ANNO-008, ANNO-048). Alternative: move goods to the nearest Warehouse, and refund part of the cost.
  5. HUD stock scope. Default: all goods of a kind anywhere, including in producer buffers and in transit (ANNO-043, ANNO-080), as the brief's showcase says. Alternative: Warehouse stock only (the Anno way).
  6. Population. Default: never shrinks in M1 (ANNO-064). Alternative: shrinks by one for each unsatisfied economy cycle, which makes starvation costly.
  7. Production timing. Default: inputs are consumed when a cycle starts, remaining time at the end of a cycle is dropped, so a cycle takes ceil(cycleSeconds / dt) ticks (ANNO-027). Alternative: carry the remainder over, or require cycleSeconds to be a multiple of tickSeconds.
  8. StoredIn with autoDestroy. koota's tests cover autoDestroy: 'orphan' together with exclusive: true (reference/koota/packages/core/tests/world.test.ts), so demolishing a building could destroy its goods through the relation. The spec asks only for the behaviour (ANNO-008, ANNO-048); the implementing item should verify the combination before relying on it and otherwise destroy the goods in demolishAt.
  9. Where the full systemsOf check sits. Default: ANNO-109 in F06, so only F06 needs W-036 (and, through it, the whole package chain W-033..W-035) and earlier features test order behaviourally (ANNO-021). Alternative: put it in F02, which then depends on W-036 before it can start.
  10. Fixed tick and caps. Default: tickSeconds 0.25, at most 20 ticks per advanceClock call, excess dropped (ANNO-023). A larger tick is cheaper but coarser for short cycles; this is a content tuning matter.
  11. Starting state. Default: one Warehouse pre-placed, so carriers exist at the start, and it can be demolished. Alternative: an empty island, where the first Warehouse is the first purchase.
  12. Content numbers. The Content table gives starting values. ANNO-068 and the 10 to 15 minute target of ANNO-108 are tuned through content only.
  13. Key map. Default: arrows, 1..5, Enter, Delete or x, Escape, Space, [, ], R. Alternative: another mapping. ANNO-F09 adds on-screen hints.
  14. Restart via world.reset(). As in demo-dnd (open question 13): koota's React tests do not cover useTrait on world traits after a reset. ANNO-106 (the UI shows the new settlement) is the canary. If it fails, restart becomes "destroy entities and re-add world traits" or a new world, and ANNO-105 changes.
  15. Changed on world traits and badges. World traits never appear in world.query (koota test), so the brief's "Changed for stock counters" is met with world.onChange and useTrait on Stock (ANNO-081) plus unconditional recompute with write-if-differs (ANNO-083). Building status badges use real Changed, Added, and Removed queries (ANNO-097). Deviation to accept: Changed(Producer) and Changed(Housing) match every working producer and house each tick, so the queries only narrow the recompute set; the "updates only when the data changed" promise holds for the Badge writes and renders (ANNO-098, ANNO-099), and the progress bar updates every tick on purpose (ANNO-035). If a real Changed(Stock) query is wanted, Stock must become an entity trait. If per-tick Changed on progress and timer is unwanted, move them to their own trait (changes ANNO-027, ANNO-060, ANNO-061).
  16. Tracking modifiers in Or, and Removed for destroyed entities. koota's tests cover Or of one modifier kind (Changed, Added, or Removed), not a mix, and no koota test shows a destroyed entity in Removed (only the skill's queries.md says it is included). The spec therefore avoids both: population and stock are recomputed unconditionally (ANNO-066, ANNO-083), the badge system uses three single-kind Or queries and skips dead entities (ANNO-097). The implementing item should add a one-off koota probe test for a destroyed entity in Removed before anything relies on it; the fallback is already the default.
  17. Which item first accepts the schedule name. createSchedule({ name, phases, systems }) is named in the goal of W-027, but W-027's Refs and steps do not list SCH-140, and the required-name check and schedule.name land in W-036 (steps S1). F02 only passes name: 'anno/tick' and asserts nothing about it (ANNO-020). The implementing item for F02 should confirm that W-027's createSchedule accepts and ignores name; if not, W-027 gains a step, not F02 a W-036 dependency.

Acceptance checklist

A human runs these once the feature named in each item is done (F01 to F06 in order); a step marked "from F05" or later is skipped until that feature is done and run again then. Each box covers one [manual] requirement. Setup for all: pnpm install, then pnpm --filter demo-anno dev, and open the printed local URL in Chrome or Edge.

  • A1 (ANNO-016, F01): island layout and contrast.
  • Set the viewport to 1280×720 and start with the default content. Expected: the island, the Build palette, and the "Gold" text are visible without scrolling the page. From F05 the Settlement HUD, the pause and speed controls, and the messages are visible too.
  • Temporarily set the content island to 24×24 (revert afterwards). Expected: the whole island still fits the viewport, and every cell label is readable.
  • Run a Lighthouse Accessibility audit in light mode, press d (focus outside any input) to switch to dark mode, and run it again. Expected: no contrast finding in the island, the palette, or the Gold text (from F05 also the HUD and the messages) in either theme.
  • A2 (ANNO-036, F02): production cycles are visible.
  • Place a Lumberjack, then a Sawmill, then a Fisher, and move the cursor onto each. Expected: each shows a progress bar and a status text such as "working 60%".
  • Demolish the starting Warehouse (from F03 its carriers would bring Wood), then watch the Sawmill. Expected: it says "idle: no Wood", and from F05 one message "Sawmill 1 has no Wood" appears.
  • Watch the Lumberjack for three cycles. Expected: progress goes from 0 to 100 three times, and from F05 the Wood counter in the HUD rises.
  • A3 (ANNO-054, F03): goods flow.
  • With the starting Warehouse, place a Lumberjack and a Sawmill, and open the Carriers region. Expected: a carrier line reads "carrying Wood to Sawmill 1 (N s)" and then goes back to "idle".
  • Demolish the Warehouse. Expected: the Carriers region becomes empty and a producer shows "blocked: … full, no carrier (build a Warehouse)" when its output is full.
  • Place a Warehouse again. Expected: carriers return and the blocked status clears.
  • A4 (ANNO-069, F04): houses and starvation.
  • Build the full chain with a Fisher and two Houses. Expected: the houses become "satisfied" and the settlers count grows; from F05 income rises too.
  • Demolish the Fisher. Expected: after the stock runs out, the houses say "stalled: no Fish". From F05 the messages list "House N has no Fish", the Needs attention region lists both, and income drops.
  • A5 (ANNO-095, F05): 60 FPS at the fastest speed.
  • With temporary content that raises the per-kind capacities so that 500 goods fit, build about 40 buildings and let 500 goods exist on a 24×24 island. Temporarily wrap the schedule.run call inside advanceClock with performance.mark and performance.measure('tick', …) (revert afterwards). Select the 4× speed.
  • In Chrome devtools, Performance tab, record 10 seconds. Expected: the FPS chart stays at 60 with no red bars, and no frame is longer than 16.7 ms.
  • In the same recording, look for Long Tasks (over 50 ms). Expected: none.
  • In the same recording, open the Timings track and read every tick measure. Expected: each tick measure is under 16 ms.
  • A6 (ANNO-108, F06): victory, bankruptcy, restart.
  • Play at speed 1 with the default content until the population reaches the goal. Expected: a "Victory" banner appears within about 15 minutes, the game stops, and placing is disabled.
  • Press R on the game area (or the Restart button). Expected: a fresh island with the start gold, no old buildings, and focus on the game area.
  • Build only Lumberjacks and Warehouses without houses. Expected: the income turns negative and a "Bankrupt" banner appears when gold drops below 0; Restart works the same way.