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 andafteredges; first used in F03, again in F04, F05 and ANNO-109), SCH-022 (order depends only on the arrays), SCH-030, SCH-031 (run,dtunchanged, no clock), SCH-041 (invaliddtthrows, mirrored by ANNO-024 in F02), SCH-065 (world.reset(), F06 test), SCH-143 (systemsOf, F06 test). SCH-140: the schedulenameis passed tocreateSchedule(W-027 builds the minimalcreateSchedule({ name, phases, systems })) but no test asserts it before F06; the required-name check andschedule.namecome with W-036, which only F06 needs (OQ17). TheOnStart, 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
kootaskill andreference/koota): trait,relation({ exclusive: true }),createWorld,world.reset(), world traits throughworld.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 filtersStoredIn(place)andStoredIn('*'),Not,Or,createAdded,createRemoved,createChanged(.github/skills/koota/references/queries.md). - Tracking modifiers inside
Or: koota tests coverOrof one modifier kind only (reference/koota/packages/core/tests/query-modifiers.test.ts: "should combine Or with Changed / Added / Removed modifiers…"). Mixing kinds inside oneOr, and a destroyed entity appearing inRemoved(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).updateEachflagsChangedautomatically 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, andStorage. It occupies exactly one cell (1×1). A producer also hasProducer, a house hasHousingandSettlers(from F04; before F04 it has only the common traits), a warehouse has theWarehousetag. The island is the grid ofwidth × heightcells. - A good is an entity with
Good { kind },kindone of'wood' | 'planks' | 'fish'. A place is a building (F02) or, from F03, a carrier. A carrier (from F03) is an entity withCarrier. stockOf(world, place, kind)is the number of goods ofkindstored inplace(ANNO-043).totalStock(world, kind)is the number of goods ofkindstored 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).dtistickSeconds, 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 traitsTreasury { gold = content.startGold, income = 0, cycleTimer = 0 },Settlement { population = 0, goal = content.goal, status = 'ongoing' },GameClock(ECS model),Builder,Stock, andMessages, and spawn every entry ofcontent.startBuildings(default: one Warehouse) through the same spawn function asplaceBuildingbut without cost. Setup with nocontentargument 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.setupSettlementMUST throw, namingsetupSettlementand 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
setupSettlementand 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 returntrueiff all hold: status is'ongoing',kindis a content building kind, the cell is inside the island, no building occupies the cell, andTreasury.gold ≥ cost(kind). It MUST then spawn the building (ANNO-006) and subtract the cost withworld.set(Treasury, …)(F05 adds arefreshStockcall, 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
placeBuildingMUST returnfalseand change nothing. One test each: unknown kind, out of bounds, occupied cell,gold = cost − 1(andgold = costsucceeds, leaving 0), status not'ongoing'(the test setsSettlement.statuswithworld.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, theWarehousetag 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 haveHousingandSettlers { count: 0 }(ANNO-060); before F04 it has only the common traits. F05 addsBadgeto the spawn function (ANNO-096). - ANNO-007 [ecs][test]:
demolishAt(world, x, y)MUST returntrueiff 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 whosetois that building (ANNO-048). F05 adds arefreshStockcall (ANNO-083). Otherwise it MUST returnfalseand 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 (ornull) and the cursor cell.selectBuilding(world, kind | null),moveCursor(world, dir)('up' | 'down' | 'left' | 'right', returnsfalseand stays at the island edge),setCursor(world, x, y)(returnsfalseoutside the island),placeAtCursor(world), anddemolishAtCursor(world)MUST be actions.placeAtCursorMUST equalplaceBuildingat the cursor with the selected kind, and MUST returnfalsewithout a selected kind.demolishAtCursorMUST equaldemolishAtat the cursor. - ANNO-011 [ux][test]: The island MUST be a labelled region ("Island") of
width × heightcells. 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 havearia-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 akeydownhandler on the game area, not onwindow, and only whenevent.targetis 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 bepreventDefaulted, so buttons keep their nativeEnterandSpaceactivation andTabkeeps its normal focus order. The handler MUST ignoreevent.repeatfor every key except the arrows, so a held arrow keeps moving the cursor and does not scroll the page. Keys of F01: arrowsmoveCursor(preventDefault),1..5selectBuilding(in the content order of the palette),EnterplaceAtCursor,DeleteorxdemolishAtCursor,EscapeselectBuilding(null). Later features addSpace,[,],Runder 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, thendocument.activeElementis the game area and anArrowRightkeydown 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-pressedfor the selected kind. Activating it MUST callselectBuildingand follow the focus rule of ANNO-012. A button whose cost exceedsTreasury.goldMUST bearia-disabledwith 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
setCursorfor it, and MUST also callplaceAtCursorwhen a kind is selected. A "Demolish" button MUST calldemolishAtCursorand bearia-disabledwhen 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
demolishAtreturns, before any tick runs, no good MUST have a danglingStoredIntarget (ANNO-040). Test: demolish a Sawmill that holds Wood and Planks, then run 20 ticks withrunTickswithout error. From F03 the same holds for the carriers of a Warehouse and the goods in them (ANNO-044) and for carriers whosetois the building (ANNO-048). - ANNO-040 [ecs][test]:
Good { kind }MUST be a trait andStoredInMUST berelation({ exclusive: true }). A good MUST be spawned with itsStoredInpair in the sameworld.spawn(Good({ kind }), StoredIn(place)). After setup, after every action, and after every tick, everyGoodentity MUST have exactly oneStoredIntarget (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 explicitremovefirst. After the callgood.targetFor(StoredIn)MUST benewPlace,world.query(StoredIn(oldPlace))MUST NOT contain it, andworld.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)andtotalStock(world, kind)MUST count goods withworld.query(Good, StoredIn(place))andworld.query(Good, StoredIn('*')), filtered byGood.kind. They MUST read a good's place only throughgood.targetFor(StoredIn)and MUST NOT rely on relation targets in anupdateEachorreadEachtuple (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 intotalStockfrom 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 phasesProduce,Carry,Consume,Economy, defined bydefinePhasewithdependsOnchained asProduce → [OnUpdate],Carry → [Produce],Consume → [Carry],Economy → [Consume](SCH-003, SCH-004).schedule.phasesMUST containPreUpdate, OnUpdate, Produce, Carry, Consume, Economy, OnValidate, PostUpdateas a contiguous order of those eight entries (SCH-101, SCH-102, SCH-008), andschedule.isManual(phase)MUST befalsefor each of the four custom phases, sorunexecutes 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 withrunTicks. After the finishing tickGameClock.tickMUST equal the number of ticks run andGameClock.elapsedthat number timesdt(clock/tickinPreUpdate), and one new Wood good MUST beStoredInthe Lumberjack (produce/cycleinProduce). 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 fromdt(SCH-011) and reads no wall clock (Date,performance,requestAnimationFrame) (SCH-031). The same sequence ofplaceBuildinganddemolishAtcalls followed by the same number of ticks MUST give the same world state and the sameMessagesin 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).
GameClockMUST be a world trait{ tick, elapsed, accumulator, speed, paused }(defaults0, 0, 0, 1, false).advanceClock(world, realDt)MUST: do nothing and return0ifpausedor status is not'ongoing'; otherwise addrealDt × speedtoaccumulator, runschedule.run(world, content.tickSeconds)once for each fulltickSecondsinaccumulator(subtracting it), up tocontent.maxTicksPerAdvanceper call, drop any time beyond that cap (leaveaccumulator < tickSeconds), and return the number of ticks run. Each tick'sdtMUST be exactlytickSeconds(SCH-031), whateverrealDtandspeedare. - ANNO-024 [ecs][test]:
advanceClockMUST throw ifrealDtisNaN, not finite, or negative, before it changes anything (as SCH-041 for the package).realDtof0MUST be accepted and run no tick. - ANNO-025 [ecs][test]:
runTicks(world, n)MUST runnticks ofschedule.run(world, tickSeconds)without readingGameClock.paused, the accumulator, or any wall clock, so tests and replays drive the pipeline directly (brief §7).advanceClockandrunTicksMUST give the same state for the same tick count (SCH-030). - ANNO-026 [ecs][test]: The system
clock/tick(PreUpdate) MUST add 1 toGameClock.tickanddttoGameClock.elapsed, withworld.set, once per tick, and nothing else. It is adefineSysteminPreUpdate(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. Ifactiveisfalseand the cycle can start (ANNO-029, ANNO-030): destroy the recipe's input goods stored in the building, setactive = true. Whileactive:progress += dt(the start tick counts). Whenprogress ≥ recipe.cycleSeconds: spawnrecipe.output.amountgoods of the output kind withStoredIn(building)in the sameworld.spawn(ANNO-040), and setactive = falseandprogress = 0(remaining time is dropped, so a cycle takesceil(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 / dtticks 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 getIdle { 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 getsIdle. - ANNO-030 [ecs][test]: A producer whose inputs are present but whose output kind is already stored at
Storage.capacityin the building MUST getBlocked { good }(the output kind), and MUST NOT consume or progress.Idletakes precedence: if inputs are missing and the output is full, the producer getsIdle. - ANNO-031 [ecs][test]:
IdleandBlockedMUST never be on a building at the same time. A producer that starts a cycle MUST have neither. Systems MUST addIdle,Blocked, or (from F04)Starvedonly 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 existingIdleMUST useset, 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'(withprogress=Producer.progress / recipe.cycleSeconds, in[0, 1)) iffactive, else'idle'or'blocked'(withgood) from the tags, else'waiting': the producer is not active and has neither tag, which holds from spawn until the firstproduce/cycleevaluates 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'iffHousing.satisfied,'starved'(withgood) iff it hasStarved, else'waiting': no consume cycle has run yet, sosatisfiedisfalseand there is noStarved(ANNO-062). Before F04 a house has noHousingandstatusOfMUST 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
statusOfdirectly (noBadge, noeconomy/badges), and the cell MUST re-evaluate it every tick (it subscribes toGameClockwithuseTrait). The detail of the cursor building MUST show itsstatusOfas text, the same way. F05 (ANNO-096..099) switches the cell toBadgeas 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 reachBlockedwith 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" witharia-valuenow(0 to 100) fromstatusOf, updating every tick. It readsstatusOfof the cursor building directly in every feature (neverBadge), 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
onRemoveand thenonAdd. Therefore no observer orRemoved(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 aMessagesentry. Test: with its ownworld.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 noIdle,Blocked, orStarvedadd can happen during the script (assert the Warehouse'sstockOfrose by 10, so the run is not vacuous) and assert thatMessages.entries.lengthequals the number ofIdle,Blocked, andStarvedadds 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, eachCarrier { to: null, remaining: 0 }, when it is spawned (ANNO-006). A carrier is idle iffto === 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 belowStorage.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 belowStorage.capacity, with the smallestdistance(S, W), ties as in 3. - Otherwise return
null. - ANNO-046 [ecs][test]: The system
carry/assign(Carry, aftercarry/move, SCH-013) MUST, for each idle carrier in entity-id order, calldecideHauland, if it returns a haul, dogood.add(StoredIn(carrier))andworld.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 intotalStockand in no building'sstockOf(ANNO-043). Test with default content: one tick in which a Lumberjack finishes its cycle MUST end with the new Wood alreadyStoredIna carrier (Produce before Carry, SCH-002), andtotalStock('wood')counts it. - ANNO-047 [ecs][test]: The system
carry/move(Carry, first: declared first, SCH-012, and beforecarry/assign, SCH-013) MUST, for each carrier with ato, subtractdtfromremainingand, when it is≤ 0, dogood.add(StoredIn(to))for the good stored in the carrier and setto = null. A carrier that delivers in a tick MUST be assignable again bycarry/assignin the same tick. A delivery MUST NOT lose or duplicate a good (ANNO-040). - ANNO-048 [ecs][test]:
demolishAtMUST, in the same call and before it returns, reset every carrier whosetois the demolished building: destroy the good stored in the carrier, setto = nullandremaining = 0(OQ4). Carriers of a demolished Warehouse are destroyed with it (ANNO-044), together with their goods. Socarry/movenever meets a deadto, 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.capacitygoods 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') ≤ 4after 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.capacityand the producer becomesBlocked(ANNO-030). That is the "nobody to carry" bottleneck. - ANNO-051 [ux][test]: When a building is
Blockedand 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 fromstatusOfplus a check whether anyCarrierexists, with noBadge(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
StoredIntarget 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 }andSettlers { count }(a separate trait that changes only on growth, ANNO-063).capacityand the needs come from content (starting values: capacity 5 settlers; needs 1 Fish and 1 Planks per cycle ofconsumeSeconds). A new house MUST start withcount: 0,timer: 0,satisfied: false. - ANNO-061 [ecs][test]: The system
consume/houses(Consume) MUST, for each house in entity-id order, adddttoHousing.timer, and whentimer ≥ consumeSecondssettimer = 0and run one consume cycle: if the house has every need in the stock stored in it (stockOf), destroy the needed goods (one each) and setsatisfied = trueand removeStarved; otherwise setsatisfied = falseand addStarved { good }with the first missing kind in content order (if it does not haveStarvedyet; if it has it,setthe kind). - ANNO-062 [ecs][test]:
satisfiedMUST 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:satisfiedisfalseand it has noStarved(ANNO-033). Needs MUST be the only goods a house takes in:decideHaulnever delivers another kind to a house (ANNO-045). - ANNO-063 [ecs][test]: The system
economy/settle(Economy, first) MUST adddttoTreasury.cycleTimerand, when it reachescontent.economyCycleSeconds, set it to 0 and run one economy cycle: for each house in entity-id order, ifsatisfied:taxes += count × taxPerSettler(the count before this cycle's growth), thencount = min(capacity, count + content.growthPerCycle); if not satisfied: no taxes and no growth (the house stalls).upkeepis the sum of the content upkeep of all buildings. ThenTreasury.gold += taxes − upkeepandTreasury.income = taxes − upkeep, both with oneworld.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 = capacityMUST still pay taxes when satisfied and MUST NOT exceedcapacity. - ANNO-065 [ecs][test]: Phase order MUST give this same-tick result (SCH-002, SCH-102): a good produced in
Produceis picked up inCarry, and a good delivered inCarryto a house is consumable byconsume/housesinConsumeof 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.populationMUST equal the sum ofSettlers.countof all houses. It is recomputed unconditionally (a sum over all houses, no change detection) byrefreshStock(world), introduced here with population only, which the systemeconomy/stock(Economy, aftereconomy/settle, SCH-013) calls once per tick. F05 extendsrefreshStockwithStock(ANNO-081) and makesplaceBuildinganddemolishAtcall it (ANNO-083). It MUST write withworld.setonly when the value differs. NoAdded,Changed, orRemovedquery is used for population (OQ16). Test: after a quiet tickworld.onChange(Settlement, spy)MUST NOT have been called; after a growth it MUST be called once. In F04 demolishing a house lowerspopulationat 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 readsstatusOfandSettlersdirectly (ANNO-034). After F05 it reads the status fromBadgeand also subscribes toSettlerswithuseTrait(house, Settlers), becauseBadgehas 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 reachSettlement.population ≥ 5within 2000 ticks and have noStarvedhouse at the end, and the same island without the Fisher MUST haveStarved { good: 'fish' }on every house andpopulation = 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, fromTreasury.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]:
StockMUST be a world trait{ counts: Record<GoodKind, number> }.refreshStock(ANNO-083, called by the systemeconomy/stock) MUST computetotalStockper kind unconditionally and writeStockwithworld.setand a newcountsobject only if at least one count differs, souseTrait(Stock)andworld.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 narrowChanged(StoredIn)to a target, and it fires only onset()orchanged(), not on add or remove. Test: a delivery that moves a good from a Warehouse to a carrier (totalStockunchanged) 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 thenStock(ANNO-081), each written only if it differs. It is the whole body of the systemeconomy/stock. F05 extendsplaceBuilding(ANNO-004) anddemolishAt(ANNO-007): each MUST callrefreshStockafter 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, andworld.get(Stock)changes without a tick; pause, demolish a House with settlers, andSettlement.populationdrops without a tick. - ANNO-084 [ecs][test]:
setPaused(world, paused)MUST setGameClock.pausedand returntrue, or returnfalseand change nothing when status is not'ongoing'. Whilepaused,advanceClockbehaves as defined in ANNO-023 (no tick,accumulatorunchanged), so game time stands still and no system runs.placeBuilding,demolishAt,selectBuilding, andmoveCursorMUST keep working while paused. - ANNO-085 [ecs][test]: Pause MUST NOT use
schedule.disableorenable. 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), andschedule.state.disabledis reserved for explicit toggles such as devtools or tests (ecs-schedule summary). Test: aftersetPaused(true)andsetPaused(false),runTicks(world, 1)MUST still advanceGameClock.tickby 1 (clock/tickis enabled;schedule.run, SCH-030), and a directrunTicks(world, 1)while paused MUST advance it too (ANNO-025). The test MUST NOT readschedule.systems,systemsOf, orisEnabled. - ANNO-086 [ecs][test]:
setSpeed(world, speed)MUST setGameClock.speedand returntrueiffspeedis incontent.speeds(default 1, 2, 4) and status is'ongoing', otherwisefalseand 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:advanceClockfor 8 s at speed 1 and for 2 s at speed 4 (withtickSeconds = 0.25,maxTicksPerAdvance ≥ 32) both run 32 ticks and end in the same world state. - ANNO-087 [ecs][test]:
MessagesMUST be an AoS world trait{ entries: Message[] }, each entry{ id, tick, kind, text }withkindone of'idle' | 'blocked' | 'starved', ids strictly increasing, oldest first. The list MUST keep at mostcontent.maxMessages(default 50) newest entries. Appending MUST useworld.setwith a new array. - ANNO-088 [ecs][test]: Setup MUST register
world.onAdd(Idle, …),world.onAdd(Blocked, …), andworld.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 currentGameClock.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.lengthequals the number ofIdle,Blocked, andStarvedadds counted by the test's own spies (the test of ANNO-042). ObservingonAddonly is deliberate: there is noonRemoveoronChangeobserver, 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
Idlelogs 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 newIdlelogs exactly one entry. - ANNO-090 [ecs][test]: Observer callbacks run synchronously inside the add. They MUST write only the
Messagesworld 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 noIdle,Blocked, orStarvedadd 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, fromcontent.speeds) MUST callsetPausedandsetSpeed. Under the key rule of ANNO-012:SpaceMUST 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, thendocument.activeElementis that button. - ANNO-092 [ux][test]: The messages MUST be a
role="log"region labelled "Messages" witharia-live="polite", listing thetextof every entry newest first, updating live. - ANNO-093 [ux][test]: A "Needs attention" region MUST list every building that currently has
Idle,Blocked, orStarvedas 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 callssetCursorand follows the focus rule of ANNO-012). - ANNO-094 [ux][test]:
useGameLoop(world)MUST calladvanceClockonce per animation frame with the real elapsed seconds since the previous frame, capped at0.1, and MUST stop when the component unmounts. The first frame MUST call it with0. Test with a fakerequestAnimationFrame. -
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,goodis''when none): a cache ofstatusOfwithoutprogress, 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 buildingBadgeequalsstatusOfminusprogress. 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, aftereconomy/stock, SCH-013) MUST recomputeBadgeonly 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)), andOr(Removed(Idle), Removed(Blocked), Removed(Starved)). EachOrholds one modifier kind from onecreateChanged/createAdded/createRemovedinstance, the shape koota tests (References). It MUST process the union inentity.id()order, skip entities for whichentity.isAlive()isfalse(demolition needs no badge update), and writeBadgewithworld.setonly ifkindorgooddiffers. Systems writeProducer,Housing, and the tags only throughupdateEach,set,add, andremove, so tracking sees every change. This is theChanged-driven status update of brief §4. - ANNO-098 [ecs][test]:
BadgeMUST be written only on a status change, not on every tick. Withworld.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 firstIdle; 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)andChanged(Housing)match every working producer and every house each tick (theirprogressandtimerchange 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) toBadge, with unchanged observable text (ANNO-011, ANNO-034, ANNO-051, ANNO-067 tests pass unchanged). A cell MUST read its status withuseTrait(building, Badge)and no longer subscribe toGameClock. A cell whoseBadgedid not change MUST NOT re-render on a tick, with two carve-outs: a house cell also subscribes toSettlersand re-renders whenSettlerschanges (ANNO-067), and a cell with aBlockedbadge re-renders when the existence of anyCarrierchanges (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 firstIdleand 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
Settlementand its setup values are defined in ANNO-001; this feature adds only the outcome.statusMUST be written only bysettlement/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 withworld.setsetTreasury.goldto 1000 andSettlement.goalto 0 (population is recomputed byeconomy/stock, so the goal is what makespopulation ≥ goalhold at the outcome check). Assert both conditions hold (gold ≥ 0,population ≥ goal, so the system would set'victory'if status were'ongoing'), run 20runTicks, and assertstatusis still'bankrupt'. - ANNO-101 [ecs][test]: The system
settlement/outcome(PostUpdate, after the custom phases, SCH-002) MUST setstatus = 'victory'whenpopulation ≥ goal, else'bankrupt'whenTreasury.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),placeBuildingandplaceAtCursor(ANNO-004, ANNO-005, ANNO-010),demolishAtanddemolishAtCursor(ANNO-007, ANNO-010),setPaused(ANNO-084),setSpeed(ANNO-086). Test: after'victory'and after'bankrupt', one call of each returnsfalse(0foradvanceClock) 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 (
startGold3, a Warehouse upkeep above 3 per economy cycle, no houses) the game MUST reach'bankrupt'at the first economy cycle that leavesgold < 0, andTreasury.incomeMUST 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 makespopulation ≥ goal. - ANNO-105 [ecs][test]:
restartSettlement(world)MUST callworld.reset()and thensetupSettlementwith the same content as the original call, remembered per world outside the ECS traits (e.g. aWeakMapinactions.ts), becauseworld.reset()removes every trait. Afterwards: all old entity handles are dead, status is'ongoing', gold isstartGold,GameClockis{ 0, 0, 0, 1, false },Messagesis 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 sameMessageskindandtextsequence. - ANNO-106 [ux][test]: When status is not
'ongoing'the UI MUST show arole="status"banner with the text "Victory" or "Bankrupt" and a "Restart" button. Activating it (orRon the game area, key rule of ANNO-012) MUST callrestartSettlementand 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 pressR. - 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 fromafteredges (SCH-013). This is the only requirement that readssystemsOf(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¶
StoredInis a koota exclusive relation, flecs'sExclusiverelationship 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_progresswithdelta_timeand 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. Changedis 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 statusBadge(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). Becauseprogressandtimerchange every tick,Changed(Producer)andChanged(Housing)narrow the recompute set only to working producers and houses; the change-only guarantee is on theBadgewrites 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
decideHaulrules 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.
- 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 < 0after 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). - Pause mechanism. Default: clock-level,
GameClock.pausedstopsadvanceClock(ANNO-084, ANNO-085). This keeps ANNO-F05 off the schedule's enable/disable (no W-031 needed). Alternatives: (a)schedule.disableof every system (pollutesschedule.state.disabled, speed not expressible), (b) a sharedrunIfthat returnsfalsewhile 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 directschedule.runto respect pause, choose (b). - 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. - 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.
- 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).
- Population. Default: never shrinks in M1 (ANNO-064). Alternative: shrinks by one for each unsatisfied economy cycle, which makes starvation costly.
- 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 requirecycleSecondsto be a multiple oftickSeconds. StoredInwithautoDestroy. koota's tests coverautoDestroy: 'orphan'together withexclusive: 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 indemolishAt.- Where the full
systemsOfcheck 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. - Fixed tick and caps. Default:
tickSeconds0.25, at most 20 ticks peradvanceClockcall, excess dropped (ANNO-023). A larger tick is cheaper but coarser for short cycles; this is a content tuning matter. - 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.
- 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.
- Key map. Default: arrows,
1..5,Enter,Deleteorx,Escape,Space,[,],R. Alternative: another mapping. ANNO-F09 adds on-screen hints. - Restart via
world.reset(). As in demo-dnd (open question 13): koota's React tests do not coveruseTraiton 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. Changedon world traits and badges. World traits never appear inworld.query(koota test), so the brief's "Changedfor stock counters" is met withworld.onChangeanduseTraitonStock(ANNO-081) plus unconditional recompute with write-if-differs (ANNO-083). Building status badges use realChanged,Added, andRemovedqueries (ANNO-097). Deviation to accept:Changed(Producer)andChanged(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 theBadgewrites and renders (ANNO-098, ANNO-099), and the progress bar updates every tick on purpose (ANNO-035). If a realChanged(Stock)query is wanted,Stockmust become an entity trait. If per-tickChangedonprogressandtimeris unwanted, move them to their own trait (changes ANNO-027, ANNO-060, ANNO-061).- Tracking modifiers in
Or, andRemovedfor destroyed entities. koota's tests coverOrof one modifier kind (Changed, Added, or Removed), not a mix, and no koota test shows a destroyed entity inRemoved(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-kindOrqueries and skips dead entities (ANNO-097). The implementing item should add a one-off koota probe test for a destroyed entity inRemovedbefore anything relies on it; the fallback is already the default. - 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 andschedule.nameland in W-036 (steps S1). F02 only passesname: 'anno/tick'and asserts nothing about it (ANNO-020). The implementing item for F02 should confirm that W-027'screateScheduleaccepts and ignoresname; 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.runcall insideadvanceClockwithperformance.markandperformance.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
tickmeasure. Expected: eachtickmeasure 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
Ron 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.