Skip to content

Status: approved

demo-ck3

Summary

apps/demo-ck3 is a playable dynasty game: the player rules House Godwin, the royal house of a simplified Kingdom of England in 1066, and uses marriages, granted titles, alliances, and the opinions of vassals to keep the crown in the family for 60 years while characters age, wed, are born, die, inherit, and revolt (brief). This spec covers the six M1 features CK3-F01 to CK3-F06, with CK3-F04 split into two requirement groups, F04a (grants, vassal chain, realm) and F04b (nearest crown, up), as the brief decides for a feature rated above M (rated L, OQ12). The simulation is a monthly pipeline (age, birth and death, succession, opinions, loyalty, end checks) on ecs-schedule: a detached manual phase chain stepped once per month by a month clock owned by the demo. The social world is modelled with ecs-relations: ChildOf titles with Name, paths, and lookup; symmetric MarriedTo and AlliedWith; exclusive and transitive VassalOf with a materialized closure; up to the nearest crown; and cascade to process lieges before vassals. The family tree is plain koota ParentOf, and an opinion is data on a koota relation pair. The demo adds no engine feature and uses only koota's public API. Save and resume (CK3-F07), keyboard hints (CK3-F08), the devtools panel (CK3-F09), scenario selection (CK3-F10), title destruction (CK3-F11), opinion history (CK3-F12), and everything marked later are out of scope here.

References

  • Brief: docs/products/demo-ck3.md §3 (core loop), §4 (ECS showcase), §5 (backlog, dependencies on ecs-relations, chosen relation order, notes), §6 (targets), §7 (seams), §8 (out of scope), §9 (starting content). concept §8. demo-rules (layout, actions, keyboard, content data). Model: demo-anno.
  • ecs-relations, by ID where used (the REL group is the unit of a package work item, REL-F01 = W-061 to REL-F08 = W-068):
  • REL-F06 ChildOf (REL-100..REL-107), parent, children (REL-101, REL-105).
  • REL-F07 Name, getPath, lookup (REL-120..REL-122, REL-126, REL-128, REL-129, REL-132, REL-133).
  • REL-F01 symmetric (REL-001, REL-003, REL-005, REL-006, REL-009, REL-010, REL-014).
  • REL-F02 isReachable (REL-020, REL-022, REL-024, REL-028).
  • REL-F03 transitive and the closure (REL-040, REL-041, REL-043, REL-045, REL-046, REL-048).
  • REL-F04 up (REL-060..REL-063, REL-065, REL-067).
  • REL-F05 cascade (REL-080..REL-083, REL-085, REL-086, REL-089).
  • KL1..KL12 are the koota limits that spec works within. This spec adds only the ones in "koota constraints" below.
  • ecs-schedule, by ID where used: SCH-003, SCH-004 (custom phases chained with dependsOn), SCH-100 (definePhase), SCH-104, SCH-107 (a detached chain is manual and run skips it), SCH-108, SCH-109 (runPhase, runPhases step it), SCH-042 (dt = 0), SCH-010 (defineSystem), SCH-012, SCH-013 (same-phase order and after edges), SCH-022 (order depends only on the arrays), SCH-038 (a phase without systems is a no-op). SCH-140: the schedule name is passed to createSchedule ('ck3/month') and no test asserts it (OQ13). The OnStart, enable/disable, conditions, sets, plugins, hooks, introspection, and per-world state 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, store }), createWorld, world traits through world.add, world.get, world.set.
  • Pairs: entity.add(Rel(target)), entity.remove(Rel(target)), entity.remove(Rel('*')), entity.has(Rel(target)), entity.targetFor, entity.targetsFor, world.query(Rel(target)), world.query(Rel('*')). Relation data: entity.get(Rel(target)), entity.set(Rel(target), { … }), relation({ store: { amount: 0 } }) (.github/skills/koota/references/relations.md, reference/koota/packages/core/tests/relation.test.ts).
  • Queries: world.query, Not, createQuery. Observers: world.onAdd, world.onRemove (called once per pair, after the add, with (source, target)).
  • Actions and React: createActions, useActions, useQuery, useTrait, WorldProvider.
  • flecs: Relationships.md (exclusive relationships, pairs with data, symmetric and transitive), ComponentTraits.md, HierarchiesManual.md (names and paths), Queries.md § Relationship Traversal (up, cascade), Systems.md § Pipelines (custom phases).

koota constraints (S1 result, W-072)

All of symmetric, transitive, isReachable, up, cascade, Name, getPath, lookup are specified in ecs-relations and are built there on koota's public API only (hooks, queries, pairs; no $internal). The limits that shape this spec:

Limit Effect on this spec Requirements
KL1, KL4: relation traits are per-world hooks, lost on world.reset() symmetric and transitive are installed in createDynasty before content is spawned. Restart creates a new world and never calls world.reset(). CK3-001, CK3-060, CK3-080, CK3-168
KL2, REL-009: hooks fire once per direction for a symmetric pair the event log observer for marriages and alliances logs only the direction where source.id() < target.id() CK3-158
KL6: neither targetsFor nor world.query order is a contract every helper and system sorts by entity.id() CK3-020
KL7: lookup scans every named entity without a ChildOf target for the first segment characters do not use Name (they use Person), so a character is never found as a title root CK3-008
koota: a relation store is a schema of primitives (SoA) Opinion data is four numeric fields, not a list of reasons CK3-121

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 month pipeline and the month clock are defined once in the ECS model below, and the features reference them. Groups map to the M1 features and later to work items: F01 (CK3-001..019), F02 (020..059), F03 (060..079), F04a (080..109), F04b (110..119), F05 (120..149), F06 (150..189). The unused numbers inside each range (for example 015..019, 053..059, 065, 066) are reserved for later requirements of that group and are never reused by another group. IDs are never renumbered. 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. When a later feature extends a requirement of an earlier one it says "extends CK3-NNN" and that requirement says "extended in Fnn".

Terms:

  • A character is an entity with Person { name, sex }, House { id }, and Age { months }. It is living iff it has no Dead tag. ageYears(c) = floor(Age.months / 12). An adult has ageYears ≥ content.adultAge.
  • A title is an entity with Title { tier } and Name { value } (the package Name, REL-120), tier one of 'kingdom' | 'duchy' | 'county'. Its parent is its ChildOf target. A title's holder is title.targetFor(HeldBy); the title is vacant if there is none. A character is landless iff it is living and no title has HeldBy(character).
  • The crown title is the title with id content.crownTitle. The player ruler (or Crown holder) is the living holder of the crown title, and has the Crown tag. The player house is Scenario.playerHouse ('godwin').
  • The liege of c is c.targetFor(VassalOf). The realm of r is the set of characters that reach r under VassalOf (REL-F02 and REL-F03 definition of "reach"). From F04a it is world.query(Reaches(r)).
  • livingLiege(world, c) is the first living entity found by following targetFor(VassalOf) from c (a visited set stops a cycle); undefined if none.
  • A month is one run of the month pipeline (ECS model). GameClock.month is the number of months run since the start (0 at start). The label of month m is "<Mon> <year>", year = content.startYear + floor(m / 12), Mon from Jan to Dec by m mod 12: "Jan 1066" for m = 0.
  • Status is Scenario.status: 'ongoing' | 'victory' | 'defeat' (shape in CK3-001; only F06 changes it, CK3-164).
  • Content values (houses, characters, titles, ages, tuning numbers, goal) come from src/content/ (CK3-003). Numbers in the Content table are starting values, not requirements.
  • Order never depends on query or relation-target order: wherever a system or helper iterates entities, it sorts by entity.id() ascending (KL6). Characters and titles are never destroyed in M1, so ids stay unique.
  • Result<R> is { ok: true } | { ok: false; reason: R }. An action that returns a refusal MUST NOT change the world. Where several refusal reasons apply, the first in the order listed wins.

CK3-F01 Title tree, paths, lookup

REL usage: REL-F06 (ChildOf, parent, children: REL-100, REL-101, REL-105) and REL-F07 (Name, getPath, lookup: REL-120, REL-121, REL-126, REL-128, REL-129, REL-132, REL-133). SCH usage: none. F01 introduces the world traits of CK3-001 and spawns every content entity (titles and characters, as data only), but characters, clock, and pairs other than ChildOf and HeldBy have no behaviour and no view until F02.

  • CK3-001 [ecs][test]: createDynasty(content?, { seed? }) MUST create a new koota world with createWorld() and return it. It MUST add the world traits GameClock { month: 0, accumulator: 0, speed: 1, paused: true }, Scenario { status: 'ongoing', reason: '', crownMonths: 0, goalMonths: content.goalMonths, playerHouse: content.playerHouse }, Selection { character: null, title: null }, EventLog { entries: [] }, and Rng (CK3-033), then spawn every title and every character of the content. Called with no content it MUST use the default content. Relation traits are installed before spawning (CK3-060, CK3-080, CK3-120, from F03, F04a, and F05), so content pairs are mirrored and counted at install time or spawn time.
  • CK3-002 [ecs][test]: createDynasty MUST throw, naming createDynasty and the offending entry, and MUST create no world, if the content violates any of: duplicate ids; a title tier whose parent tier is wrong (kingdom has no parent, duchy has a kingdom parent, county has a duchy parent); a title name that is empty or contains / (a / makes getPath throw, REL-125); two titles with the same name under the same parent (lookup picks the lowest id, REL-128, so the content must avoid it); a holder, liege, parent, or spouse that names an unknown character; crownTitle that names no kingdom title; a character age below 0.
  • CK3-003 [ecs][test]: Titles, names, tiers, houses, characters, ages, holders, tuning numbers (CK3-020, CK3-030, CK3-067, CK3-120, CK3-151), clock numbers, and the goal MUST come only from content. A test MUST pass custom content (other names, numbers, and tree shape) to createDynasty and assert that the spawned values equal it. Systems and actions MUST NOT contain such literals.
  • CK3-004 [ecs][test]: Every title MUST be spawned with Title { tier }, Name { value }, and, except the kingdom, ChildOf(parent) (REL-100). The default content MUST have 1 kingdom (England), 2 duchies (East Anglia, Mercia), and 6 counties (Norfolk, Suffolk, Essex under East Anglia; Cheshire, Warwickshire, Leicestershire under Mercia). children(world, title) MUST return its children in ascending entity.id() order (REL-105), and parent(world, title) its parent (REL-101).
  • CK3-005 [ecs][test]: HeldBy MUST be relation({ exclusive: true }) with the title as source and the holder as target, plain koota (no package trait). Content holders MUST become title.add(HeldBy(holder)) pairs at spawn. A title without a content holder MUST have no HeldBy pair (vacant). holderOf(world, title) MUST return title.targetFor(HeldBy). In the default content at least two counties are vacant (so there is land to grant, brief §9).
  • CK3-006 [ecs][test]: titlePath(world, title) MUST return getPath(world, title) (REL-121): "England" for the kingdom, "England/Mercia/Cheshire" for Cheshire. It MUST throw, naming getPath, for an entity that is not alive in world (REL-134).
  • CK3-007 [ecs][test]: findTitle(world, path) MUST return lookup(world, path) (REL-126): the title whose path equals path, exactly and case-sensitively, or undefined. A relative or empty path, a path with an empty segment (England//Mercia), and an unknown path MUST return undefined (REL-129).
  • CK3-008 [ecs][test]: For every title t of the default content findTitle(world, titlePath(world, t)) === t (REL-132). Characters MUST NOT carry Name, so findTitle(world, 'Harold Godwinson') and the name of any other character MUST return undefined (a character is never a root for lookup, KL7). Name is added to titles only.
  • CK3-009 [ecs][test]: selectTitle(world, title | null) and selectCharacter(world, character | null) MUST set Selection.title and Selection.character with world.set and return true, or return false and change nothing when the argument is not an alive title (resp. character) of world. Selecting a title MUST NOT change the selected character and the other way round. They work in every status.
  • CK3-010 [ux][test]: A "Titles" region MUST show the title tree as nested lists: one item per title, nested under its parent, siblings in children order (CK3-004). Each item MUST be a button with the text "<Tier> of <name>: held by <holder name>" or "<Tier> of <name>: vacant" ("Duchy of Mercia: held by Edwin"), with aria-pressed for the selected title. Activating it MUST call selectTitle. The text MUST update live when a holder changes (useQuery and useTrait).
  • CK3-011 [ux][test]: When a title is selected, a detail MUST show its full path as text from titlePath ("Path: England/Mercia/Cheshire"), its tier, and its holder or "vacant". With no title selected it says "No title selected".
  • CK3-012 [ux][test]: A text input labelled "Find title by path" MUST resolve what is typed with findTitle on every change. A match MUST call selectTitle and show "Found: <path>". A non-empty path with no match MUST show "No title at <path>" and leave the selection unchanged. An empty input MUST show nothing. Test: typing England/Mercia/Cheshire selects Cheshire and Mercia finds nothing.
  • CK3-013 [ux][test]: Every action of F01 (select a title, find a title) MUST work with the keyboard alone: items are native buttons in focus order, and the input is a native text input. State (selected, vacant) MUST be conveyed by text or aria-pressed, never by colour alone (brief §6).
  • CK3-014 [ux][manual]: The title tree, its detail, and the lookup input MUST fit a 1280×720 viewport without scrolling the page, with WCAG AA contrast in both themes. Verify with the Acceptance checklist (A1).

CK3-F02 Characters, time, aging, death, succession

REL usage: none (plain koota ParentOf, VassalOf, and HeldBy; the family tree MUST NOT use ChildOf, whose autoDestroy would destroy children, REL-100). SCH usage: SCH-003, SCH-004, SCH-100 (the phase chain, CK3-024), SCH-104, SCH-107 (detached, manual, CK3-024), SCH-108, SCH-109 (runPhases, CK3-026), SCH-042 (dt = 0, CK3-026), SCH-010 (defineSystem), SCH-022 (CK3-025), SCH-038 (empty later phases are a no-op, CK3-024). F02 has one system per phase except Age, so same-phase order (SCH-012, SCH-013) first matters in F03. F02 builds the schedule and the clock (ECS model); later features add systems to it.

  • CK3-020 [ecs][test]: Each content character MUST be spawned with Person { name, sex } ('m' or 'f'), House { id } (a content house id), and Age { months } (ageYears × 12 plus the content extraMonths, default 0). The default content MUST have about 20 characters of 4 houses (Godwin, Leofric, Wessex, Dunkeld), including those named in brief §9, and at most content.maxCharacters (default 60, brief §6) characters. Characters have no Name (CK3-008).
  • CK3-021 [ecs][test]: ParentOf MUST be relation() (plain, non-exclusive, no autoDestroy) with the parent as source and the child as target, and VassalOf MUST be relation({ exclusive: true }) with the vassal as source and the liege as target. In F02 both are plain koota (the transitive install of VassalOf comes in F04a). Content parents and liege entries MUST become ParentOf(child) and VassalOf(liege) pairs at spawn. Test: a character that dies keeps its ParentOf pairs in both directions (a dead character stays in the family tree, CK3-022).
  • CK3-022 [ecs][test]: A dead character MUST keep its entity and its traits (including Age, frozen) and have the Dead tag. Characters and titles MUST NOT be destroyed in M1. A dead character MUST keep its ParentOf pairs (as source and target) and MUST be shown as dead in every list (CK3-044, CK3-045).
  • CK3-023 [ecs][test]: GameClock MUST be a world trait { month, accumulator, speed, paused } (CK3-001 defaults: 0, 0, 1, true, the game starts paused). monthLabel(content, month) MUST implement the label of the Terms ("Jan 1066", monthLabel(…, 13) is "Feb 1067"), and yearOf(content, month) MUST return content.startYear + floor(month / 12).
  • CK3-024 [ecs][test]: Month pipeline (the one normative definition of schedule, phases, and order; the Month pipeline table and CK3-025 refer to it). The pipeline MUST be one schedule created with name: 'ck3/month' (passed only, not asserted, OQ13) and the custom phases Age, Vitals, Succession, Opinions, Loyalty, EndChecks, defined by definePhase with dependsOn chained as Vitals → [Age], Succession → [Vitals], Opinions → [Succession], Loyalty → [Opinions], EndChecks → [Loyalty] (SCH-003, SCH-004). No phase has an edge to a builtin phase, so the chain is detached and every phase is manual (SCH-104). schedule.run(world, dt) MUST therefore run nothing (SCH-107), and a month is schedule.runPhases(world, Age, EndChecks, 0) (SCH-109, SCH-042). A phase that no system uses yet MUST be a no-op (SCH-038): F02 has no system in Opinions, Loyalty, and EndChecks.
  • CK3-025 [ecs][test]: Systems MUST run in the order of the Month pipeline table within one month, whatever their declaration order (SCH-022). Behavioural tests, with a scripted Rng (CK3-033): a character that reaches age in month n is aged before it can die (age/grow before vitals/death: a character whose age bracket changes this month uses the new age); a king that dies in Vitals already has its heir holding the crown title when runMonths(world, 1) returns (Succession after Vitals, CK3-036). The later phases are tested where their systems are introduced (CK3-072, CK3-156, CK3-166).
  • CK3-026 [ecs][test]: stepMonth(world) MUST return false and change nothing if Scenario.status is not 'ongoing'. Otherwise it MUST run exactly one month (CK3-024) and return true. It MUST NOT read GameClock.paused, the accumulator, or any wall clock, so a paused player can step one month. runMonths(world, n) MUST run n months with the same state change as n calls of stepMonth, without reading paused or status, so tests and replays drive the pipeline directly (brief §7).
  • CK3-027 [ecs][test]: Month clock (the one normative definition; CK3-046 and the ECS model refer to it). advanceClock(world, realDt) MUST: do nothing and return 0 if GameClock.paused or status is not 'ongoing'; otherwise add realDt × speed to accumulator, run stepMonth once for each full content.secondsPerMonth in accumulator (subtracting it), up to content.maxMonthsPerAdvance per call, drop any time beyond the cap (leave accumulator < secondsPerMonth), and return the number of months run. The month clock is a world singleton advanced by the demo, because ecs-schedule v0 has no intervals or fixed step (brief §5 notes).
  • CK3-028 [ecs][test]: advanceClock MUST throw if realDt is NaN, not finite, or negative, before it changes anything. realDt of 0 MUST be accepted and run no month.
  • CK3-029 [ecs][test]: setPaused(world, paused) MUST set GameClock.paused and return true, or return false and change nothing when status is not 'ongoing'. setSpeed(world, speed) MUST set GameClock.speed and return true iff speed is in content.speeds (default 1, 2, 4) and status is 'ongoing', else false and change nothing. Speed only multiplies the accumulated time: the state after the same number of months MUST be identical at every speed.
  • CK3-030 [ecs][test]: The system clock/month (Age, first) MUST add 1 to GameClock.month with world.set, once per month, and nothing else. The system age/grow (Age, after clock/month) MUST add 1 to Age.months of every living character, and MUST NOT change a dead character's age. Both are defineSystems (SCH-010).
  • CK3-031 [ecs][test]: Death chance MUST come from content: deathChance(content, ageYears) is the chance of the first bracket of content.mortality whose upTo is greater than ageYears (starting values: below 16: 0.0003, below 40: 0.0004, below 60: 0.001, below 80: 0.006, else 0.02 per month). The system vitals/death (Vitals, first) MUST process the living characters in entity-id order and consume exactly one rng.next() for each; a character dies iff next() < deathChance(…), and dying MUST be adding the Dead tag with character.add(Dead). vitals/death MUST NOT change anything else in F02 (extended in F03 and F05; relation cleanup is added by the feature that owns each relation: CK3-067 for MarriedTo, CK3-124 for AlliedWith and Opinion; succession cleans VassalOf, CK3-041).
  • CK3-033 [ecs][test]: All randomness MUST go through the world trait Rng (an AoS trait holding { next(): number } in [0, 1), brief §7), and no system reads Math.random. createSeededRng(seed) MUST give a deterministic sequence. createDynasty(content, { seed }) MUST install a seeded Rng when given a seed (and a fixed default seed otherwise), and tests MUST be able to install a scripted Rng with world.set(Rng, …).
  • CK3-034 [ecs][test]: heirOf(world, character) MUST be a pure read returning the living child of character (a target of character.targetsFor(ParentOf)) with the greatest Age.months, ties broken by the lowest entity.id(), or undefined. Sex and age of the child are not considered (OQ7). A dead child is never the heir and a grandchild is never considered.
  • CK3-035 [ecs][test]: livingLiege(world, c) MUST follow VassalOf through dead characters and return the first living one, or undefined (no package function is needed: a visited set ends any cycle). It MUST NOT change the world.
  • CK3-036 [ecs][test]: The system succession/titles (Succession) MUST process every dead character that still holds a title or is still in a VassalOf pair, in entity-id order. For a dead character D, with heir H = heirOf(D) and liege L = livingLiege(D), the successor is S = H ?? L (it can be undefined). In this order: (1) every title with HeldBy(D), in ascending title id, MUST become title.add(HeldBy(S)) (an exclusive replace), or title.remove(HeldBy(D)) when S is undefined; (2) every living vassal V ≠ H with VassalOf(D), in id order, MUST become V.add(VassalOf(S)), or V.remove(VassalOf(D)) when S is undefined; (3) if H exists, H MUST become H.add(VassalOf(L)) when L exists, else H.remove(VassalOf('*')); (4) every remaining VassalOf pair with D as source or target MUST be removed; then syncRulers(world) (CK3-042). Step 3 is extended in F04a (CK3-089, loop guard).
  • CK3-037 [ecs][test]: Example (the dead ruler's titles pass to the eldest living child, brief CK3-F02): a Crown holder holding the kingdom and a duchy has children aged 14 (already dead), 12, and 9; a scripted Rng kills him, and runMonths(world, 1) MUST leave both titles held by the 12 year old, who has Crown and Ruler. With no living child and a liege the titles MUST become the liege's. With no living child and no liege (the Crown holder) the titles MUST become vacant and nobody has Crown.
  • CK3-038 [ecs][test]: The vassals of a dead ruler MUST follow the titles (CK3-036 step 2): after succession to the heir, every former vassal has the heir as liege; after a return to the liege, every former vassal has the liege as liege; with no successor every former vassal is independent (no VassalOf pair).
  • CK3-039 [ecs][test]: The heir of a dead vassal MUST become a vassal of the dead vassal's living liege (CK3-036 step 3), replacing any old VassalOf pair of the heir (including VassalOf(D) itself), or independent when there is no living liege.
  • CK3-040 [ecs][test]: Deaths in the same month MUST give the same result as if they had happened one after another in id order: a vassal and his liege dying in the same month MUST leave the vassal's heir with the vassal's titles and the liege's living liege (or heir) as liege, whatever their ids. Test both id orders.
  • CK3-041 [ecs][test]: Invariant after succession/titles, for every dead character D: no title has HeldBy(D), and no VassalOf pair has D as source or target. Test: a seeded run of 240 months with the default content and a mortality scaled up in custom content, checking it after every month.
  • CK3-042 [ecs][test]: syncRulers(world) MUST make Ruler present on exactly the living characters that hold a title or that content marks independent (the foreign ruler Malcolm III, brief §9), and Crown on exactly the living holder of the crown title (at most one character). It MUST be called by createDynasty after spawn and by succession/titles after each month with a change (and from F04a by grantTitle). It adds a tag only when missing and removes it as soon as the condition fails.
  • CK3-043 [ecs][test]: familyOf(world, c) MUST be a pure read returning { parents, siblings, children }, each sorted by entity.id(): parents are the characters with ParentOf(c) (world.query(ParentOf(c))), children are c.targetsFor(ParentOf), and siblings are the other children of c's parents (full and half siblings, each once). Dead characters are included.
  • CK3-044 [ux][test]: A "Characters" region MUST list every character as a button with the text "<name>, House <house name>, age <years>", plus " (dead)" for a dead one ("Harold Godwinson, House Godwin, age 44"), in entity-id order. Activating it MUST call selectCharacter, with aria-pressed for the selected one. Age text updates live (useTrait).
  • CK3-045 [ux][test]: A "Family tree" region for the selected character MUST show Parents, Siblings, and Children as nested lists of names from familyOf, a dead relative with " (dead)" (not by colour alone). With no character selected it says "No character selected".
  • CK3-046 [ux][test]: A "Time" region MUST show the month label ("Jan 1066") as text, a toggle button "Pause" (aria-pressed, text "Pause" or "Paused"), a "Step" button (one month, stepMonth), and a "Speed" group of buttons "1×", "2×", "4×" from content.speeds (aria-pressed). They MUST call setPaused, stepMonth, and setSpeed. While status is not 'ongoing' they are disabled. The Pause and Speed buttons keep focus when activated.
  • CK3-047 [ux][test]: The game area MUST be a focusable container (tabIndex=0, label "Game") that holds all regions. Key rule (the only one; later features 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, an input, or another region MUST NOT run any game-key handler and MUST NOT be preventDefaulted, so buttons keep native Enter and Space and Tab keeps its order. The handler MUST ignore event.repeat. Keys: Space toggles pause (preventDefault), . steps one month, [ selects the next lower speed and ] the next higher. The game area MUST have focus after mount. d stays the template's theme toggle. On-screen key hints are CK3-F08.
  • CK3-048 [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.
  • CK3-049 [ecs][test]: The same content, seed, and sequence of actions followed by the same number of months MUST give the same world state and (from F06) the same EventLog in two fresh worlds (brief §7). Dice (Rng) MUST be consumed in this order inside one month: one draw per living character in id order in vitals/death, then (from F03) the birth draws (CK3-069); a month in which nobody is eligible consumes only the death draws. Test with a scripted Rng that records the draws.
  • CK3-050 [ecs][test]: Scenario: a seeded run of 240 months with the default content MUST finish without throwing, and after every month: every Age.months of a living character is one more than before, no dead character changed, Crown is on at most one living character, and CK3-041 holds.
  • CK3-051 [ux][manual]: A user who lets the clock run at each speed MUST see the month label advance, ages change, characters die, and titles pass, with the controls and the Family tree legible. Verify with the Acceptance checklist (A2).
  • CK3-032 [ecs][test]: Every system of the month pipeline MUST be a pure function (world, dt) that reads only the world and dt (always 0, SCH-042), and MUST NOT read Date, performance, requestAnimationFrame, or Math.random (brief §7). Month length is only content.secondsPerMonth (CK3-027).

CK3-F03 Marriage and births

REL usage: REL-F01 (symmetric: REL-001, REL-003, REL-005, REL-006, REL-009, REL-010, REL-014). SCH usage: SCH-012, SCH-013 (same phase, vitals/birth after vitals/death, CK3-072). No new package feature beyond REL-F01.

  • CK3-060 [ecs][test]: MarriedTo MUST be relation({ exclusive: true }), and createDynasty MUST call symmetric(world, MarriedTo) before it spawns any content (REL-001). Content spouse entries (one side listed) MUST end as mirrored pairs (REL-005): for every married pair a.has(MarriedTo(b)) and b.has(MarriedTo(a)). Installing symmetric MUST NOT change any other relation (REL-014).
  • CK3-061 [ecs][test]: arrangeMarriage(world, a, b) MUST return Result<'ended' | 'dead' | 'same' | 'sex' | 'minor' | 'married' | 'kin'>, { ok: true } iff: status is 'ongoing' ('ended' otherwise); both are living characters ('dead'); a !== b ('same'); they are of different sex ('sex'); both are adults ('minor'); neither has a MarriedTo pair ('married', a marriage is never replaced, so the exclusive replacement of REL-006 is not used by the action); and they are not kin ('kin'): one is a parent of the other, or they share a parent (familyOf). A refusal changes nothing.
  • CK3-062 [ecs][test]: On success the action MUST call a.add(MarriedTo(b)) once, and nothing else writes the pair: both a.has(MarriedTo(b)) and b.has(MarriedTo(a)) MUST be true at once (REL-001), each spouse has exactly one MarriedTo pair, and the Widowed tag (CK3-067) MUST be removed from both.
  • CK3-063 [ecs][test]: eligibleSpouses(world, c) MUST be a pure read returning the characters x for which arrangeMarriage(world, c, x) would succeed, sorted by entity.id(). It never includes c.
  • CK3-064 [ecs][test]: Marriage MUST be one man or woman with one spouse (brief §8): after any sequence of actions and months every living character has at most one MarriedTo pair, and x.has(MarriedTo(y)) iff y.has(MarriedTo(x)).
  • CK3-067 [ecs][test]: Extends CK3-031. vitals/death, for each character that dies, MUST also character.remove(MarriedTo('*')) (REL-003, which removes the mirrored pair on the spouse, REL-010 for termination) and, if it had a spouse, add Widowed to the surviving spouse. After vitals/death no MarriedTo pair has a dead end. Widowed MUST stay until the character marries again (CK3-062), and a character that dies MUST lose Widowed, so two spouses dying in the same month leave it on neither. A widow is an eligible unmarried adult (CK3-061).
  • CK3-068 [ecs][test]: Content MAY mark a character widowed: true (brief §9: Queen Edith, the dowager Gytha). At spawn it MUST have the Widowed tag and no spouse.
  • CK3-069 [ecs][test]: The system vitals/birth (Vitals, after vitals/death, SCH-013) MUST consider, in entity-id order, every living female character M that has a living spouse (her husband F) and is fertile: content.adultAge ≤ ageYears(M) ≤ content.fertileMaxAge (default 40), has fewer than content.maxChildren (default 6) ParentOf targets, and the number of characters (living and dead) is below content.maxCharacters. For each it MUST consume one rng.next(); a child is conceived iff next() < content.birthChance (default 0.03 per month). If conceived it MUST consume one more draw, and the child is male iff that draw is < 0.5.
  • CK3-070 [ecs][test]: A birth MUST spawn one character with Person { name, sex }, House of the father F (the father's house, OQ6), and Age { months: 0 }, and add ParentOf(child) on both M and F. The name MUST be the next unused entry of content.names[sex] (index = number of characters of that sex ever spawned, modulo the list length; no draw). A birth MUST NOT change MarriedTo, VassalOf, or HeldBy. The child is landless and has no liege.
  • CK3-071 [ecs][test]: Caps MUST hold: at maxChildren or at maxCharacters no birth happens and no draw is consumed for the capped mother. Test with custom content and a scripted Rng that always conceives.
  • CK3-072 [ecs][test]: Order: a married woman who dies in vitals/death this month MUST NOT give birth this month (she is not living when vitals/birth runs, and the widowed father is not considered). Test with a scripted Rng that kills her.
  • CK3-073 [ecs][test]: familyOf (CK3-043) MUST show a newborn in the children of both parents and in the siblings of the older children of those parents, and its parents MUST be the two.
  • CK3-074 [ecs][test]: Scenario: with custom content birthChance: 1 and one fertile married couple and a scripted Rng, 3 months give exactly 3 children (all ParentOf pairs, ages 0, 1, 2 months by the end), then the cap stops births.
  • CK3-075 [ux][test]: A "Marriage" region MUST show, for the selected character, the text "Spouse: <name>", "Widow" or "Widower" (a Widowed character), or "Unmarried"; a select labelled "Spouse to be" listing eligibleSpouses by name; and an "Arrange marriage" button that calls arrangeMarriage. The button MUST be aria-disabled with the visible text of the refusal reason ("Already married", "Too young", …) while no valid pair is chosen. After a success the spouse text MUST appear for both characters at once (select either one to see it).
  • CK3-076 [ux][test]: A refusal MUST be shown as text next to the button (role="status"), naming the reason. Marriage actions follow the key rule of CK3-047 (native buttons and select, reachable by Tab and operable with Enter and Space).
  • CK3-077 [ux][manual]: A user who selects a character, arranges a marriage, and lets months pass MUST see the spouse on both sides, a widow after a death, and children appear in the Family tree. Verify with the Acceptance checklist (A3).
  • CK3-078 [ecs][test]: A scripted death of one spouse followed by a marriage of the widow MUST leave her with exactly one MarriedTo pair, no Widowed, and the late husband's ParentOf pairs unchanged.

CK3-F04a Grants, vassal chain, realm

REL usage: REL-F02 (isReachable: REL-020, REL-022, REL-024, REL-028), REL-F03 (transitive and the closure: REL-040, REL-041, REL-043, REL-045, REL-046, REL-048), and REL-F06 (parent for the title's liege, REL-101). SCH usage: none new. This group has no up: it is CK3-F04b (the brief says up is split out of CK3-F04 when CK3-F04 is rated above M; rated L).

  • CK3-080 [ecs][test]: Reaches MUST be relation() (non-exclusive, no store), a dedicated closure that only the package writes (REL-040), and createDynasty MUST call transitive(world, VassalOf, { closure: Reaches }) before it spawns any content (REL-041 mirrors pairs that exist at install time). The demo MUST NOT add or remove a Reaches pair itself.
  • CK3-081 [ecs][test]: Invariant (REL-046): after createDynasty, after every action, and after every month, for every pair of characters (x, y), x.has(Reaches(y)) iff isReachable(world, x, VassalOf, y). The test MUST run a seeded 240-month scenario with deaths, successions, and grants (including a grant to a title holder that re-points his subtree, CK3-095) and check it after every month. This includes the removals of death and succession (REL-043, REL-045).
  • CK3-082 [ecs][test]: realmOf(world, ruler) MUST return world.query(Reaches(ruler)) sorted by entity.id() (REL-048), living characters only. It MUST be empty for a character with no vassals, and MUST NOT contain ruler. It contains every direct and indirect vassal and nobody else. A rebel (from F06) and its own vassals are not in the Crown holder's realm.
  • CK3-083 [ecs][test]: liegeChain(world, c) MUST return the lieges of c from the nearest to the highest, by following targetFor(VassalOf) (a visited set ends any cycle), and [] for an independent character. For Leofwine → Gyrth → Harold it is [Gyrth, Harold]. vassalsOf(world, ruler) MUST return the direct vassals world.query(VassalOf(ruler)) sorted by entity.id().
  • CK3-084 [ecs][test]: titleLiege(world, title) MUST return the holder of the nearest held proper ancestor of title (following parent(world, t), REL-101, skipping vacant ancestors), else the Crown holder, else undefined. For a county in a held duchy it is the duchy's holder, for a county in a vacant duchy and for a duchy it is the Crown holder (the kingdom's holder).
  • CK3-085 [ecs][test]: grantTitle(world, title, grantee) MUST return Result<'ended' | 'nocrown' | 'title' | 'taken' | 'grantee' | 'loop'>, { ok: true } iff: status is 'ongoing' ('ended' otherwise); a Crown holder exists ('nocrown'); title is an alive title of tier 'county' or 'duchy' ('title'); it is vacant or held by the Crown holder ('taken', OQ2); grantee is a living adult character other than the Crown holder ('grantee'; a character that already holds titles or has a liege is eligible, so the grant can re-point him under another liege, CK3-087); and the loop check of CK3-086 passes ('loop').
  • CK3-086 [ecs][test]: Loop check. With L = titleLiege(world, title), the grant MUST be refused with 'loop' iff grantee === L or isReachable(world, L, VassalOf, grantee) is true (REL-020: the liege would become the vassal of his own vassal). A refusal changes nothing. The refusal MUST be reachable with the default content and no constructed world. Tests: (a) vacant Suffolk, whose liege is Gyrth (holder of East Anglia), granted to Gyrth: refused (grantee === L). (b) Suffolk granted to Edwin succeeds (Edwin now serves Gyrth, CK3-095), then vacant Cheshire, whose liege is Edwin (holder of Mercia), granted to Gyrth: refused, because Edwin reaches Gyrth. In both, VassalOf, HeldBy, and Reaches are unchanged by the refused call.
  • CK3-087 [ecs][test]: On success the action MUST do, in this order: title.add(HeldBy(grantee)) (an exclusive replace if the Crown holder held it), grantee.add(VassalOf(L)) (replaces any old liege), and syncRulers(world) (so the grantee becomes a Ruler). The closure MUST then be consistent (CK3-081). It MUST NOT write a pair of any other character: the vassals of the grantee keep VassalOf(grantee) and move with him. Extended in F05 (CK3-125, opinion modifiers).
  • CK3-088 [ecs][test]: eligibleGrantees(world, title) MUST be a pure read returning the characters that pass the 'ended', 'nocrown', 'title', 'taken', and 'grantee' checks of grantTitle (CK3-085), sorted by entity.id(), and grantableTitles(world) the titles that pass the 'ended', 'nocrown', 'title', and 'taken' checks. Neither applies the loop check (CK3-086), so the user can pick a pair the loop check refuses and read the reason (as CK3-128 does for alliances). Both read the same rules as grantTitle.
  • CK3-089 [ecs][test]: Succession loop guard. Extends CK3-036 step 3: the heir H MUST become a vassal of L only if isReachable(world, L, VassalOf, H) is false (REL-020); otherwise H MUST have no VassalOf pair (independent). Test with a constructed world in which H is already a (transitive) liege of L. Then CK3-081 still holds.
  • CK3-090 [ux][test]: The detail of the selected character MUST show "Liege chain: Gyrth, Harold Godwinson" (nearest first, from liegeChain) or "Independent", the direct "Vassals: …" (or "None") from vassalsOf, and for a character with a non-empty realm "Realm below: N characters" with their names from realmOf as a list. All update live (useQuery).
  • CK3-091 [ux][test]: A "Grant a title" region MUST show, for the selected title, "Can be granted" or the refusal text of its reason ('title': "Only counties and duchies can be granted", 'taken': "Already held by <name>"), a select labelled "Grantee" listing eligibleGrantees by name, and a "Grant" button that calls grantTitle. The button MUST be aria-disabled with visible reason text while the title is refused ('title', 'taken') or no grantee is chosen. A grantee that only the loop check refuses stays selectable and the button stays enabled, so the refusal of CK3-092 is reachable in play.
  • CK3-092 [ux][test]: A refusal MUST be shown as text next to the button (role="status"): 'loop' as "Refused: <liege> would become the vassal of his own vassal <grantee>", or "Refused: <grantee> would be his own liege" when grantee === liege. A success MUST show "<grantee> now holds <title path> and serves <liege>", and the Titles tree (CK3-010) and the detail of CK3-090 MUST update live. Selecting the title or the grantee and operating "Grant" MUST work with the keyboard alone (CK3-047).
  • CK3-093 [ecs][test]: Scenario with default content: granting vacant Suffolk (liege Gyrth) to one landless adult and vacant Cheshire (liege Edwin) to another MUST give each the expected VassalOf target, make each a Ruler, put each in the realm of his liege and of the Crown holder, and change the Reaches closure by exactly the pairs of those two characters (REL-043, REL-046).
  • CK3-095 [ecs][test]: Scenario with default content: granting vacant Suffolk to Edwin (a duke, liege Harold, with vassal Morcar) MUST give Edwin VassalOf(Gyrth) replacing VassalOf(Harold), leave his titles and VassalOf(Edwin) of Morcar unchanged, and put both in the realm of Gyrth and of the Crown holder. The Reaches closure MUST change by exactly the added pairs Edwin → Gyrth and Morcar → Gyrth (Edwin → Harold and Morcar → Harold remain, now through Gyrth; REL-043, REL-046), and CK3-081 holds.
  • CK3-094 [ux][manual]: A user who grants a vacant county MUST see the title's holder, the new liege chain, and the realm below the Crown holder change at once, and a refused grant MUST say why. Verify with the Acceptance checklist (A4).

CK3-F04b Nearest crown (up)

REL usage: REL-F04 (up: REL-060, REL-061, REL-062, REL-063, REL-065, REL-067). SCH usage: none. This group is the up half of the brief's CK3-F04 and is its own feature item (brief: split when CK3-F04 is rated above M). It depends on F04a (VassalOf pairs and the closure to cross-check).

  • CK3-110 [ecs][test]: crownOf(world, c) MUST return up(world, c, VassalOf, Crown) (REL-060): the nearest entity above c along VassalOf that has the Crown tag, or undefined. VassalOf is exclusive, so the walk has one path and "first" is the nearest (REL-063). For Leofwine → Gyrth → Harold (Harold has Crown) it returns Harold; for an independent character it returns undefined (REL-062).
  • CK3-111 [ecs][test]: crownOf(world, crownHolder) MUST return undefined (self is not considered by default, REL-061), and up(world, crownHolder, VassalOf, Crown, { self: true }) MUST return him. A dead character MUST give undefined (it has no VassalOf pair after succession, CK3-041). crownOf MUST NOT change the world.
  • CK3-112 [ecs][test]: Cross-check with the closure of F04a: for every living character c, crownOf(world, c) !== undefined iff the Crown holder exists and c.has(Reaches(crownHolder)), and then crownOf(world, c) === crownHolder. Run over the seeded scenario of CK3-081 after every month.
  • CK3-113 [ecs][test]: crownOf MUST follow later changes (no cache, REL-065): after a grant moves a character under another liege, after a succession re-points vassals, and (from F06) after a revolt, the result matches the new chain. It MUST NOT throw for a dead character (an alive entity with a Dead tag, REL-067 throws only for an entity that is not alive in world), and MUST throw, naming up, for an entity that is not alive in world.
  • CK3-114 [ux][test]: The detail of the selected character (CK3-090) MUST also show "Nearest crown: <name>" from crownOf, or "No crown above" for an independent or crown-less character, or "This is the crown" for the Crown holder. It updates live.
  • CK3-115 [ux][test]: When the Crown holder dies and the crown title becomes vacant, every character's detail MUST show "No crown above" after the month (no Crown tag exists anywhere), without a reload.
  • CK3-116 [ecs][test]: up MUST be used only through crownOf: no other helper walks VassalOf upwards looking for a trait (liegeChain and livingLiege walk for other purposes: a full chain, and the first living entity).

CK3-F05 Opinions and alliances

REL usage: REL-F01 (symmetric: REL-001, REL-014 for a second relation, REL-003). SCH usage: none new (the system opinions/refresh is the one system of Opinions). The relation Opinion is plain koota relation data.

  • CK3-120 [ecs][test]: AlliedWith MUST be relation() (non-exclusive), and createDynasty MUST call symmetric(world, AlliedWith) before it spawns any content. Content allies entries (one side listed) MUST end as mirrored pairs. The second symmetric install MUST NOT change MarriedTo behaviour and the other way round (REL-014): marrying two characters adds no AlliedWith pair, and allying two rulers adds no MarriedTo pair.
  • CK3-121 [ecs][test]: Opinion MUST be relation({ store: { sameHouse: 0, marriedIn: 0, granted: 0, passedOver: 0 } }) with the holder of the opinion as source and its target as target. It is data on the pair, read with holder.get(Opinion(target)) and written with holder.set(Opinion(target), …).
  • CK3-122 [ecs][test]: opinionOf(world, holder, target) MUST be a pure read returning the sum of the four fields of the pair (0 when there is no pair), and opinionReasons(world, holder, target) the non-zero fields, in the fixed order sameHouse, marriedIn, granted, passedOver, as { key, label, value } with the labels "Same house", "Married into the family", "Granted a title", "Passed over for a title". Test with fields { sameHouse: 20, granted: 25, passedOver: -10 }: value 35 and three reasons.
  • CK3-123 [ecs][test]: The system opinions/refresh (Opinions) MUST call refreshOpinions(world), which MUST: (1) for each living character c in id order, define its targets as the Crown holder (if one exists and is not c) and the liege of c (if any and distinct), and c.add(Opinion(t)) for each target that has no pair yet; (2) remove every Opinion pair of c whose target is neither of those; (3) set sameHouse = content.opinion.sameHouse iff c's house equals the target's house, else 0; (4) set marriedIn = content.opinion.marriedIn iff c has a spouse whose house equals the target's house, else 0. It MUST leave granted and passedOver unchanged, MUST write a pair only if a field differs, and MUST be callable at any time (it is also called by createDynasty after spawn, and by every successful marriage, grant, and alliance action so the UI is right while paused). Starting values: sameHouse 20, marriedIn 15.
  • CK3-124 [ecs][test]: Extends CK3-031 and CK3-067. For each character that dies, vitals/death MUST also character.remove(AlliedWith('*')) (REL-003, the pair disappears on the ally too) and remove every Opinion pair of the dead as source, and every pair that targets the dead (world.query(Opinion(dead)), each remove(Opinion(dead))). After vitals/death no AlliedWith or Opinion pair has a dead end.
  • CK3-125 [ecs][test]: Extends CK3-087. After a successful grant the action MUST, before syncRulers, ensure the grantee has Opinion(crown) (add when missing) and set its granted = content.opinion.granted (starting value 25), and for every other living adult character that is a direct vassal of the Crown holder (VassalOf(crown)), and is not the grantee, set passedOver = max(passedOver + content.opinion.passedOver, content.opinion.passedOverMin) (starting values: −5 per grant, floor −30), after ensuring the pair. Then refreshOpinions. It MUST NOT change any other field. The rule is the same for a grantee of any kind (CK3-085): the grantee is never passed over, whether or not he was a direct vassal of the Crown holder before the grant.
  • CK3-126 [ecs][test]: Opinion pairs MUST NOT be created for a dead target, and refreshOpinions MUST NOT recreate a pair removed by CK3-124. After the Crown holder changes (death and succession) the opinions of the new Crown holder start with granted and passedOver at 0: the old pairs were removed with the dead ruler (OQ4).
  • CK3-127 [ecs][test]: proposeAlliance(world, other) MUST return Result<'ended' | 'nocrown' | 'ruler' | 'self' | 'allied' | 'refused'>, { ok: true } iff: status is 'ongoing' ('ended' otherwise); a Crown holder exists ('nocrown'); other is a living Ruler ('ruler'); other is not the Crown holder ('self'); the Crown holder has no AlliedWith pair with other ('allied'); and opinionOf(world, other, crown) ≥ content.opinion.allianceMin ('refused', starting value 15). On success it MUST call crown.add(AlliedWith(other)) once, so both crown.has(AlliedWith(other)) and other.has(AlliedWith(crown)) are true (REL-001), then refreshOpinions. A refusal changes nothing. Acceptance is deterministic and consumes no rng draw.
  • CK3-128 [ecs][test]: eligibleAllies(world) MUST be a pure read returning the living rulers that are neither the Crown holder nor allied with him, sorted by entity.id(). It does not check the opinion (so the user can try and read the reason).
  • CK3-129 [ecs][test]: Scenario with default content: Malcolm III (House Dunkeld, a Ruler) refuses an alliance at the start (his opinion of the Crown holder is below allianceMin), accepts it after the widowed Queen Edith of House Godwin is married to him with arrangeMarriage (his marriedIn is then 15), and the alliance then shows on both. No month needs to run between the steps: refreshOpinions runs in each action (CK3-123).
  • CK3-130 [ecs][test]: An alliance MUST end with the death of either ruler (CK3-124). The heir of a dead Crown holder has no alliance of his own (OQ4).
  • CK3-131 [ux][test]: A "Opinions" region MUST list every living character with an Opinion pair towards the Crown holder as "<name>: <signed value>" ("Gyrth: +20", "Edwin: -10"), sorted by entity id, with the reasons of the selected character as a list of "<label> <signed value>" ("Same house +20", "Passed over for a title -10") from opinionReasons. The sign is shown as text, never by colour alone. It updates live (useTrait, useQuery) after every action and month.
  • CK3-132 [ux][test]: An "Alliance" region MUST show, for the selected ruler, "Opinion of you: <value> (needs at least <allianceMin>)", a "Propose alliance" button that calls proposeAlliance, and the result as text: "<name> accepts" or "<name> refuses: opinion too low" (and the text of the other reasons). A ruler allied with the Crown holder MUST show "Allied with: <name>" on both rulers (the detail of each). The button follows the key rule of CK3-047 (native, keyboard operable).
  • CK3-133 [ux][test]: After a marriage, a grant, or an alliance the opinion values and reasons MUST be correct without running a month (the actions call refreshOpinions, CK3-123).
  • CK3-134 [ux][manual]: A user who marries a Godwin widow to the king of Scotland MUST be able to propose an alliance that is then accepted and shown on both rulers, and to see the reasons behind a refusal. Verify with the Acceptance checklist (A5).

CK3-F06 Loyalty, revolts, log, outcome, restart

REL usage: REL-F05 (cascade: REL-080, REL-081, REL-082, REL-083, REL-085, REL-086, REL-089). SCH usage: SCH-012, SCH-013 (declaration order, same phase), SCH-022 (determinism), SCH-109 (stepping, CK3-024). No systemsOf: the order is tested behaviourally (CK3-025, CK3-156, CK3-166), see OQ13.

  • CK3-150 [ecs][test]: Loyalty MUST be a trait { lowMonths } (default 0) on every character (spawned with it). Rebel MUST be a tag. Both are plain koota.
  • CK3-151 [ecs][test]: The system loyalty/check (Loyalty) MUST get its processing order from cascade(world, [Person, Not(Dead)], VassalOf) (REL-080), the living characters in level order: all characters with no liege first, then their vassals, ordered by entity.id() inside one depth (REL-081, REL-082), a character with a liege after its liege (REL-086). For each character c in that order that has a liege L: if opinionOf(world, c, L) ≤ content.revoltThreshold (starting value −10) then Loyalty.lowMonths += 1, else lowMonths = 0. A character with no liege is skipped.
  • CK3-152 [ecs][test]: Revolt. When lowMonths ≥ content.revoltMonths (starting value 3), c MUST revolt: c.remove(VassalOf('*')) (it breaks away), c.add(Rebel), lowMonths = 0. Its titles stay held by it and its vassals keep VassalOf(c), so they leave the Crown holder's realm with it, and the closure is consistent (CK3-081, REL-043). Test: a duke with lowMonths = 2 and low opinion revolts, and crownOf(duke) is undefined and crownOf(his county holder) is undefined (CK3-110).
  • CK3-153 [ecs][test]: Order matters (liege before vassal): a vassal whose liege revolted earlier in the same pass MUST NOT revolt in that pass, and its lowMonths MUST be left unchanged (not incremented, not reset). Test: a duke and his count both at lowMonths = 2 with low opinion: after one month only the duke is a Rebel, and the count's lowMonths is still 2, whatever their entity ids. In the next month the count is evaluated against his new independent liege, as any vassal.
  • CK3-154 [ecs][test]: The opinion read by loyalty/check MUST be the one just refreshed in the same month (opinions/refresh runs before it, CK3-156). The Crown holder never revolts (no liege). A vassal with opinion > revoltThreshold for any month has lowMonths reset to 0 (three low months must be in a row).
  • CK3-155 [ecs][test]: Revolt MUST only change VassalOf, Rebel, and Loyalty of the revolting character. It MUST NOT destroy, kill, or retitle anyone, and MUST NOT add Rebel to the revolting character's vassals.
  • CK3-156 [ecs][test]: Order test: a vassal whose opinion drops below the threshold this month because of a death (the Crown holder died and its heir is of another house) uses the new opinion in loyalty/check of the same month, not last month's (Opinions before Loyalty).
  • CK3-157 [ecs][test]: EventLog MUST be an AoS world trait { entries: Entry[] }, each entry { id, month, kind, text } with kind one of 'birth' | 'death' | 'marriage' | 'alliance' | 'title' | 'revolt' | 'outcome', ids strictly increasing, oldest first. It MUST keep at most content.maxEvents (default 100) newest entries, and appending MUST use world.set with a new array. logEvent(world, kind, text) is the only writer.
  • CK3-158 [ecs][test]: createDynasty MUST register these observers, each exactly once, after it spawned all content, so setup logs nothing (a new world has an empty log): world.onAdd(Dead, c => 'death') ("Aldred died aged 61"); world.onAdd(MarriedTo, (a, b) => …) logging only when a.id() < b.id() (REL-009: once per direction, so one entry per marriage, "A married B"); world.onAdd(AlliedWith, …) the same way ("A allied with B"); world.onAdd(ParentOf, (parent, child) => …) logging a 'birth' only when the parent is female (one entry per child, "A was born to B"); world.onAdd(HeldBy, (title, holder) => …) ("Cheshire is now held by Morcar", for grants and successions alike); world.onAdd(Rebel, c => …) ("Edwin revolts and breaks away"). Each entry has the GameClock.month it happened in. The outcome entry is appended by outcome/check itself with logEvent (CK3-164: a change of a world trait is not an add event).
  • CK3-159 [ecs][test]: Observer callbacks run synchronously inside the add. They MUST write only the EventLog world trait and MUST NOT mutate the observed entity or any other trait (no re-entry). Pair removals (death cleanup, revolt, succession replacing a liege) MUST NOT write any entry: no onRemove observer exists. Test: a month in which a character dies and its marriage pair is removed logs exactly one entry (the death).
  • CK3-160 [ecs][test]: A symmetric pair MUST log exactly once: arranging one marriage appends one entry, not two (REL-009), and proposing one alliance appends one entry.
  • CK3-161 [ecs][test]: realmOutline(world) MUST return [{ character, liege, depth }, …] for the Crown holder and his realm: first the Crown holder with depth 0 and no liege, then cascade(world, [Reaches(crown)], VassalOf) (REL-080), each with its liege and depth = liegeChain(…).length. Every entry's liege MUST appear earlier in the list, depth MUST NOT decrease (level order, REL-081), and ties are ordered by entity.id(). It is empty when there is no Crown holder. Rebels and their vassals are not in it.
  • CK3-162 [ux][test]: A "Realm" region MUST show the realm outline as nested lists, one item per entry with the text "<name>" followed by ", holds <title names>" when the character holds titles ("Gyrth, holds East Anglia"), built from realmOutline so a vassal is nested under its liege. It lists the rebels and independent living characters in a separate "Independent" list. It updates live after every action and month.
  • CK3-163 [ecs][test]: Scenario MUST be { status, reason, crownMonths, goalMonths, playerHouse } (CK3-001). status MUST be written only by outcome/check (CK3-164). reason is '' while ongoing, 'goal' for victory, and 'crown-lost' or 'house-extinct' for defeat.
  • CK3-164 [ecs][test]: The system outcome/check (EndChecks) MUST do nothing when status is not 'ongoing'. Otherwise, in this order: (1) if no living character has the player house, set status = 'defeat', reason = 'house-extinct'; (2) else if the crown title has no living holder, or the living holder is not of the player house, set status = 'defeat', reason = 'crown-lost'; (3) else Scenario.crownMonths += 1, and if crownMonths ≥ goalMonths (starting value 720, 60 years) set status = 'victory', reason = 'goal'. It MUST append one 'outcome' entry with logEvent when it sets a status. A status other than 'ongoing' stays as it is until restart, whatever happens afterwards.
  • CK3-165 [ecs][test]: Gating on status is defined where each action is, and this requirement only checks it end to end: stepMonth, advanceClock, setPaused, setSpeed (F02), arrangeMarriage (F03), grantTitle (F04a), proposeAlliance (F05) each return false, 0, or { ok: false, reason: 'ended' } when status is not 'ongoing' and change nothing. selectCharacter and selectTitle stay allowed. runMonths is for tests only and is not gated.
  • CK3-166 [ecs][test]: Defeat MUST be reachable: with a scripted Rng that kills the Crown holder in a world where he has no living child and no liege, runMonths(world, 1) ends with status = 'defeat', reason = 'crown-lost' in the same month (succession leaves the title vacant, EndChecks after Succession). With custom content where every character of the player house dies, the reason is 'house-extinct'.
  • CK3-167 [ecs][test]: Victory MUST be reachable: with custom content (goalMonths: 12, mortality 0) status becomes 'victory' on the twelfth stepMonth, and not earlier. The reason is 'goal' and the label of the end month is in the entry.
  • CK3-168 [ecs][test]: Restart MUST be a new world: the App calls createDynasty(content, { seed }) again with the content and the seed of the first call (kept in App state, not in the ECS) and mounts the new world in WorldProvider. It MUST NOT call world.reset() (hooks and observers would be lost, KL4). Afterwards: the new world has month 0, status 'ongoing', an empty log, paused clock, the start content, all observers and relation traits installed once (a new marriage logs one entry and is mirrored), and no entity of the old world is shown. A test MUST play a scripted game, restart, replay the same inputs, and assert the same final state and the same EventLog kind and text sequence.
  • CK3-169 [ux][test]: When status is not 'ongoing' the UI MUST show a role="status" banner with "Victory: House <name> held the crown for 60 years (<end year>)", "Defeat: the crown is lost", or "Defeat: House <name> has died out" and a "Restart" button. Activating it (or R on the game area, key rule of CK3-047) MUST restart (CK3-168) and move focus to the game area. When the game ends while a button has focus and becomes disabled, focus MUST move to the game area so the keyboard user can press R.
  • CK3-170 [ux][test]: An "Events" region MUST be a role="log" labelled "Events" with aria-live="polite", listing "<Mon year>: <text>" of every entry, newest first, updating live. It reads only EventLog.
  • CK3-171 [ux][test]: A "Crown" region MUST show "Crown held: <years> of <goal years> years" (floor(crownMonths / 12) and goalMonths / 12), the current Crown holder's name or "Crown vacant", and updates live.
  • CK3-172 [ux][test]: A Rebel MUST be shown as text ("(rebel)") in the Characters list (CK3-044) and in the detail, and a vassal with lowMonths > 0 MUST show "Unhappy for N months" in the detail of the selected character. State is not conveyed by colour alone.
  • CK3-173 [ecs][test]: Scenario with default content and a seeded Rng: 120 months with the "end turn only" policy (no marriage, no grant, no alliance) MUST finish without throwing and MUST keep CK3-041, CK3-081, and CK3-112 true after every month, regardless of whether the game ended (a game that ended simply stops stepping; the test uses runMonths, which is not gated).
  • CK3-174 [ux][manual]: With 60 characters and 12 titles at the fastest speed the UI MUST stay at 60 FPS, and one month of logic MUST take under 16 ms (brief §6). Verify with the Acceptance checklist (A6, steps 1 to 3).
  • CK3-175 [ux][manual]: A player MUST be able to reach victory or defeat in a 15 to 20 minute session (a mix of speeds and pauses) and restart in both cases, and to see why a vassal revolted (opinion reasons, loyalty text). Verify with the Acceptance checklist (A6, steps 4 to 6).

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); people and places are brief §9's illustrative, simplified roster (approximate, not verified history).

File Content
houses.ts godwin, leofric, wessex, dunkeld (id, name)
titles.ts england (kingdom), east-anglia, mercia (duchies), norfolk, suffolk, essex, cheshire, warwickshire, leicestershire (counties): id, name, tier, parent, optional holder (some counties vacant)
characters.ts about 20 characters: id, name, sex, house, ageYears, optional extraMonths, parents, spouse, liege, widowed, independent, allies, names pool names.m and names.f for births
scenario.ts startYear 1066, playerHouse 'godwin', crownTitle 'england', goalMonths 720 (the goal year 1126), maxCharacters 60
rules.ts adultAge 16, fertileMaxAge 40, maxChildren 6, birthChance 0.03, mortality brackets (CK3-031), opinion { sameHouse 20, marriedIn 15, granted 25, passedOver -5, passedOverMin -30, allianceMin 15 }, revoltThreshold -10, revoltMonths 3
clock.ts secondsPerMonth 1, speeds [1, 2, 4], maxMonthsPerAdvance 12, maxEvents 100

Default starting holders and lieges (approximate): the kingdom is held by Harold Godwinson (Crown); East Anglia by Gyrth; Mercia and Warwickshire by Edwin of House Leofric; Norfolk by Leofwine; Suffolk, Essex, Cheshire, and Leicestershire are vacant. Gyrth and Edwin are vassals of Harold, Leofwine is a vassal of Gyrth, and Morcar (landless) is a vassal of Edwin. Malcolm III of House Dunkeld is an independent ruler with no title in the tree. Queen Edith and the dowager Gytha are widowed. Marriages: Harold and Ealdgyth (of House Leofric).

Traits (src/ecs/traits.ts)

Trait Kind From Shape
Name SoA F01 { value: string } (from @workspace/ecs-relations, titles only, CK3-008)
Title SoA F01 { tier }
Person SoA F01 { name, sex }
House SoA F01 { id }
Age SoA F01 { months }
Dead, Ruler, Crown tag F02
Widowed tag F03
Loyalty SoA F06 { lowMonths }
Rebel tag F06

Relations (src/ecs/relations.ts)

Relation Definition From Notes
ChildOf package ChildOf (@workspace/ecs-relations) F01 title → parent title (REL-100)
HeldBy relation({ exclusive: true }) F01 title → holder
ParentOf relation() F02 parent → child. Never ChildOf (REL-100 autoDestroy)
VassalOf relation({ exclusive: true }) F02 vassal → liege. transitive installed from F04a (REL-040)
MarriedTo relation({ exclusive: true }) F03 symmetric installed before content (REL-001)
Reaches relation() F04a closure of VassalOf, written only by the package (REL-040)
Opinion relation({ store: { sameHouse, marriedIn, granted, passedOver } }) F05 holder → target, data on the pair
AlliedWith relation() F05 symmetric installed before content (REL-001, REL-014)

World traits (singletons, added by createDynasty)

Trait Shape
GameClock { month, accumulator, speed, paused } (CK3-023)
Scenario { status, reason, crownMonths, goalMonths, playerHouse } (CK3-163)
Selection { character, title } (selected entities or null)
Rng AoS { next(): number } (CK3-033)
EventLog AoS { entries }, replaced on every append (CK3-157)

World traits are not queryable (koota), so views subscribe with useTrait and tests with world.onChange.

Month pipeline (defined once)

The schedule 'ck3/month', its phases, and their order are defined in CK3-024 only, and one month is runPhases(world, Age, EndChecks, 0) (CK3-026). This table is the single list of systems with their phase and order; the features reference it. "Cleanup" extensions of vitals/death are per feature, in the requirements named.

Phase Order System From Does
Age 1 clock/month F02 month counter (CK3-030)
Age 2 age/grow F02 living characters get one month older (CK3-030)
Vitals 1 vitals/death F02 one draw per living character, Dead (CK3-031); cleans MarriedTo and Widowed (CK3-067, F03), AlliedWith and Opinion (CK3-124, F05)
Vitals 2 vitals/birth F03 conception draws, newborns, ParentOf (CK3-069, CK3-070)
Succession 1 succession/titles F02 titles and vassals of the dead pass on, VassalOf cleanup, syncRulers (CK3-036)
Opinions 1 opinions/refresh F05 refreshOpinions (CK3-123)
Loyalty 1 loyalty/check F06 cascade order, low-opinion counter, revolt (CK3-151..CK3-153)
EndChecks 1 outcome/check F06 defeat, crown months, victory (CK3-164)

OnStart, sets, plugins, hooks, runIf, and enable/disable are not used in M1.

Month clock (defined once)

GameClock and advanceClock(world, realDt) are defined in CK3-027 only; they are the only place where real time becomes game months. The schedule never reads a clock (SCH-031, SCH-042). Accumulator, speed, and pause live in the world, so they are saved with it later (CK3-F07). useGameLoop (CK3-048) calls advanceClock from requestAnimationFrame; tests and replays call runMonths (CK3-026); the Step button calls stepMonth.

Concern Mechanism Not
Pause GameClock.paused gates advanceClock (CK3-027, CK3-029); Step still works schedule.disable, runIf
Speed multiplies accumulated time, dt stays 0 for the pipeline (CK3-027, CK3-029) changing dt
End of game Scenario.status gates actions and the clock (CK3-165)

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

stepMonth, advanceClock, runMonths, setPaused, setSpeed, selectCharacter, selectTitle, arrangeMarriage, grantTitle, proposeAlliance, findTitle, refreshOpinions, syncRulers, logEvent. createDynasty(content, { seed }) is the single setup and the restart (CK3-168). Pure helpers: titlePath, holderOf, heirOf, livingLiege, familyOf, eligibleSpouses, eligibleGrantees, grantableTitles, eligibleAllies, realmOf, vassalsOf, liegeChain, titleLiege, crownOf, realmOutline, opinionOf, opinionReasons, deathChance, monthLabel, yearOf, createSeededRng. Views never mutate traits; they call actions. The refusal types are in the requirements.

Views (src/ui/)

Folder Content Requirements
titles/ title tree, detail, lookup input CK3-010, CK3-011, CK3-012
characters/ character list, character detail (chain, vassals, realm, crown, opinion reasons, spouse) CK3-044, CK3-090, CK3-114, CK3-172
family/ family tree CK3-045
time/ clock, pause, step, speed CK3-046
marriage/ marriage panel CK3-075, CK3-076
grants/ grant panel CK3-091, CK3-092
diplomacy/ opinions list, alliance panel CK3-131, CK3-132
realm/ realm outline, crown status CK3-162, CK3-171
log/ event log CK3-170
outcome/ victory or defeat banner, restart CK3-169
input/ key handling on the game area, useGameLoop CK3-047, CK3-048

Keys (only when the game area itself has focus, CK3-047): Space pause, . step, [ and ] speed, R restart (when over). Everything else is native buttons, selects, and inputs, in focus order. d stays the template's theme toggle.

ecs-relations usage per feature

Feature REL requirements used Package work items it needs (done before it starts) Notes
CK3-F01 REL-100, REL-101, REL-105, REL-120, REL-121, REL-126, REL-128, REL-129, REL-132, REL-133, REL-134 W-066 (REL-F06), W-067 (REL-F07) order is F06, F07 (brief)
CK3-F02 none none ParentOf, HeldBy, VassalOf are plain koota; family tree must not use ChildOf
CK3-F03 REL-001, REL-003, REL-005, REL-006, REL-009, REL-010, REL-014 W-061 (REL-F01) MarriedTo
CK3-F04a REL-020, REL-022, REL-024, REL-040, REL-041, REL-043, REL-045, REL-046, REL-048, REL-101 W-062 (REL-F02), W-063 (REL-F03), W-066 (REL-F06, for parent) grants, loop check, closure, realm
CK3-F04b REL-060, REL-061, REL-062, REL-063, REL-065, REL-067 W-064 (REL-F04) crownOf; cross-check needs F04a
CK3-F05 REL-001, REL-003, REL-014 W-061 (REL-F01) AlliedWith is the second symmetric relation; Opinion is plain koota
CK3-F06 REL-080, REL-081, REL-082, REL-083, REL-085, REL-086 W-065 (REL-F05) cascade in loyalty/check and realmOutline
(all) REL-F08 (W-068) not used by any M1 requirement README and preconditions only; Triage MAY leave it off the feature items

ecs-schedule usage per feature

Feature SCH requirements used Work items it needs (done before it starts) Notes
CK3-F01 none none no system runs in F01
CK3-F02 SCH-003, SCH-004, SCH-100, SCH-104, SCH-107, SCH-108, SCH-109, SCH-042, SCH-010, SCH-022, SCH-038 W-027, W-028, W-029 builds the schedule and the clock; name passed to createSchedule, SCH-140 not asserted (OQ13)
CK3-F03 SCH-012, SCH-013 W-027, W-028, W-029 vitals/birth after vitals/death
CK3-F04a none new W-027, W-028, W-029
CK3-F04b none none
CK3-F05 none new W-027, W-028, W-029 the system of Opinions
CK3-F06 SCH-012, SCH-013, SCH-022, SCH-109 W-027, W-028, W-029 no systemsOf (OQ13); no W-030 to W-037

W-030 to W-037 (OnStart, enable/disable, conditions, per-world state, sets, plugins, introspection, hooks) are not used by any M1 behaviour or test. The month pipeline is stepped with runPhases on manual phases, which W-029 covers (SCH-107..SCH-109).

Differences from flecs

  • Relation traits are installed per world with a call that returns an unsubscribe (symmetric, transitive), not set once as relation properties, and they are lost on world.reset() (KL1, KL4). The demo therefore never resets a world and restarts with a new one (CK3-168).
  • MarriedTo and AlliedWith are flecs Symmetric relationships. VassalOf is Exclusive plus Transitive, but transitivity is not applied inside queries: Reaches is a materialized closure and isReachable a function (ecs-relations differences).
  • up and cascade are functions, not query terms: up returns the entity that holds the trait, cascade returns an array in level order (ecs-relations differences, brief decision).
  • ParentOf replaces flecs ChildOf for the family tree because koota autoDestroy has no "do not delete" mode for a hierarchy: a ChildOf child dies with its parent, a family tree must not.
  • Opinion is data on a relation pair (Opinion store), flecs pairs with a component payload. The reasons are four numeric fields (SoA), because a koota relation store is a schema of primitives.
  • The month pipeline is a manual phase chain stepped once per month on a detached schedule, with the real-time to month conversion in the clock (CK3-027), where flecs progress() runs the pipeline with delta_time and a time scale. Pipelines for turn-based use are the same pattern as dnd/turn.
  • Observers are koota world.onAdd, synchronous and called once per pair direction, not flecs observers with events (CK3-158, CK3-159). There is no onRemove observer: removals never log.
  • Dead characters are tagged, not deleted, so the family tree stays whole, where flecs would delete the entity. "Cleanup" in the brief is therefore the removal of pairs (MarriedTo, AlliedWith, Opinion, VassalOf) at death, not entity destruction (title destruction is CK3-F11).

Out of scope

  • Everything in brief §8: a map, armies, gold, wars (CK3-F13, CK3-F15), claims, succession laws other than "eldest living child", culture, religion, personality traits, stress, schemes, secrets (CK3-F14), pregnancy detail, bastards, divorce, concubines, opinion modifiers other than the four named in CK3-F05, tiers deeper than Kingdom/Duchy/County, more than one playable house, invasions and historical event scripts, multiplayer, and 3D.
  • CK3-F07 (save and resume, ecs-serialize), CK3-F08 (on-screen key hints and polish beyond the keys listed), CK3-F09 (devtools panel), CK3-F10 (scenario selection), CK3-F11 (title destruction and ChildOf autoDestroy cleanup), CK3-F12 (opinion history, timeline), CK3-F13..F15.
  • Re-pointing the existing vassals of a county holder when a duchy above it is granted, opinion decay, opinion of anyone other than the Crown holder and the liege, representation of grandchildren, and elective or partition succession.
  • Any change to packages/ecs-*. A missing package feature becomes a package work item. A demo-ck3 scenario group in the ecs-relations spec (its Out of scope notes it should be added once this spec exists) is a separate 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. Resolved (user decision): grantee eligibility. Any living adult other than the Crown holder can receive a grant, not only landless characters, so the loop refusal is reachable in play with the default content (CK3-085, CK3-086, CK3-088, CK3-091, CK3-095). The brief's backlog row for CK3-F04 still says "landless character"; the brief is not edited here and this spec governs until the brief is updated.
  2. What "vacant" means after a return to the liege. The brief returns a dead ruler's titles to the liege. Default: the liege becomes the holder, and a title held by the Crown holder is also grantable (CK3-085, 'taken' otherwise), so land the Crown holder inherits can be rewarded. Alternative: strictly vacant only (then returned land can never be granted). Affects: CK3-037, CK3-085.
  3. Passed over. The brief names "passed over for a title" without a rule. Default: each grant lowers passedOver by 5 (floor −30) for every other living adult direct vassal of the Crown holder, and nothing decays (CK3-125). Alternative: landless candidates only, or a decay per year. Affects: CK3-125.
  4. A new Crown holder starts from zero. Opinion pairs and alliances of a dead Crown holder are removed with him (CK3-124, CK3-126), so the heir inherits no granted, passedOver, or alliance. Alternative: re-target the pairs to the heir. Affects: CK3-124, CK3-126, CK3-130.
  5. Loyalty counts only opinion of the liege. A vassal's loyalty reads Opinion(liege); only the Crown holder's passed-over and granted modifiers are written by actions (CK3-125), so only direct vassals of the Crown holder can be angered by the player. Vassals of dukes revolt only through sameHouse and marriedIn changes (death, marriage). Alternative: apply grant modifiers to the title's liege. Affects: CK3-123, CK3-125, CK3-151.
  6. House of a child. Default: the father's house, so a Godwin king's child is Godwin and the crown cannot pass to another house through the mother (the crown-lost reason 'crown-lost' for another house exists but is reachable only with content that makes a non-Godwin the holder). Alternative: choose the house of the parent of the same sex. Affects: CK3-070, CK3-164.
  7. Heir. Default: eldest living child, sex and age ignored, including minors (brief: "eldest living child"). Alternative: adults only. Affects: CK3-034.
  8. Foreign ruler. Default: Malcolm III is a Ruler by content independent, with no title in the tree, so the tree stays at 1 + 2 + 6 titles (brief CK3-F01) and Crown stays on the single English crown holder. Alternative: a Scotland title outside the England tree (a second root for lookup). Affects: CK3-042, CK3-110, CK3-127.
  9. Initial clock. Default: the game starts paused so the player can read the realm first (brief core loop step 1). Alternative: start running at 1×. Affects: CK3-001, CK3-023.
  10. Event log scope. Default: six observers and one direct write for the outcome (CK3-158), and a cap of 100 entries. The log is not the timeline of CK3-F12. Affects: CK3-157, CK3-158.
  11. Name and characters. Default: characters use Person, not Name, because lookup scans every named entity without a ChildOf target for its first segment (KL7), and a character called like a title would otherwise be found. Alternative: name characters with Name and a ChildOf-free filter. Affects: CK3-008, CK3-020.
  12. Size and split of the features. CK3-F04 is rated L (three package areas, two actions with five refusal reasons each, a closure invariant, and three views) and is split as the brief decides: up is F04b (CK3-110..116). CK3-F02 is also large (clock, aging, death, succession, family tree, controls, keys); the spec keeps it one group, and Triage MAY split it into clock and aging (CK3-020..030, CK3-032, CK3-033, CK3-044..051) and death and succession (CK3-031, CK3-034..043). Affects: Triage only.
  13. Schedule name and systemsOf. createSchedule({ name: 'ck3/month', … }) is built as in W-027, and no test asserts the name (SCH-140 comes with W-036). Pipeline order is tested behaviourally (CK3-025, CK3-156, CK3-166) rather than with systemsOf (SCH-143), so no feature needs W-036 and the chain W-030 to W-035. If a table check as in demo-anno (ANNO-109) is wanted, it goes in F06 and F06 then needs W-033 and W-036.
  14. Restart with a new world. createDynasty returns a fresh world and the App swaps it in WorldProvider (CK3-168), so no hook, observer, or schedule state outlives the game and world.reset() is not used. Alternative: world.reset() and install again (as demo-dnd, DND-034), which needs koota React tests for useTrait on world traits after a reset (not covered). CK3-168 and CK3-169 are the canary.
  15. Content numbers. The Content table gives starting values. Win and defeat odds, the 15 to 20 minute session (CK3-175), and the revolt pressure are tuned through content only, never by changing a requirement. The mortality and birth numbers are chosen so that a 60 year game is neither certain nor impossible.
  16. Single crown. Exactly one title is the crown, so crownOf and the realm closure agree (CK3-112) and up shows the top of the chain. With several Crown holders (a later empire tier or a second kingdom) up returns the nearest one and the cross-check of CK3-112 changes. Out of M1.

Acceptance checklist

A human runs these once the feature named in each item is done (F01 to F06 in order); a step marked "from F03" 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-ck3 dev, and open the printed local URL in Chrome or Edge.

  • A1 (CK3-014, F01): title tree layout and contrast.
  • Set the viewport to 1280×720 and start with the default content. Expected: the Titles tree (9 titles), the title detail, and the "Find title by path" input are visible without scrolling the page. From F02 the Characters list, the Time controls, and the Family tree are visible too.
  • Select Cheshire, then type England/Mercia/Cheshire in the input. Expected: the detail says Path: England/Mercia/Cheshire and the title is selected; typing Mercia shows "No title at Mercia".
  • 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 Titles tree or the detail in either theme (from F02 also the Characters list and the Time controls).
  • A2 (CK3-051, F02): time passes.
  • Click Step three times, then press Pause so that it reads "Paused" and click "4×", then Pause again. Expected: the label goes Jan 1066, Feb 1066, Mar 1066, Apr 1066, then advances quickly at 4×.
  • Select a character and read the Family tree. Expected: parents, siblings, and children are listed; dead relatives show "(dead)".
  • Run at 4× until a ruler dies (or the 60 year goal is near). Expected: his titles have a new holder in the Titles tree, and the new holder's liege chain makes sense in the character detail.
  • A3 (CK3-077, F03): marriage and births.
  • Select an unmarried adult woman, choose an eligible man in "Spouse to be", and click "Arrange marriage". Expected: both characters show "Spouse: " at once.
  • Let 24 months pass at 4×. Expected: some couples have children, who appear under "Children" and "Siblings" of the right characters, with age in years rising.
  • Let a married ruler die. Expected: the surviving spouse shows "Widow" or "Widower" and can be chosen as a spouse again.
  • A4 (CK3-094, F04a, F04b): grants, chain, crown.
  • Select Suffolk (vacant) and Morcar, and click "Grant". Expected: "Morcar now holds England/East Anglia/Suffolk and serves Gyrth", the Titles tree shows Morcar as holder, and Morcar's detail shows a liege chain and "Nearest crown: Harold Godwinson".
  • Select Mercia's holder and read "Realm below". Expected: his vassals are listed. Select the Crown holder: the whole realm is listed.
  • Try a grant the rules refuse (a duchy already held, or a minor). Expected: a clear reason is shown and nothing changes.
  • Grant Suffolk to Edwin, then select Cheshire and Gyrth and click "Grant". Expected: the first grant makes Edwin a vassal of Gyrth (his liege chain shows Gyrth, then Harold Godwinson). The second shows "Refused: Edwin would become the vassal of his own vassal Gyrth" and nothing changes. Also, Suffolk to Gyrth on a fresh game shows "Refused: Gyrth would be his own liege".
  • A5 (CK3-134, F05): opinions and alliances.
  • Select any character and read the Opinions region. Expected: the opinion of the Crown holder is signed text with reasons such as "Same house +20".
  • Select Malcolm III and click "Propose alliance". Expected: "Malcolm III refuses: opinion too low".
  • Select Queen Edith, choose Malcolm III in "Spouse to be", click "Arrange marriage", then propose the alliance again. Expected: it is accepted, and "Allied with" shows on both rulers.
  • A6 (CK3-174 and CK3-175, F06): revolts, log, win, lose, restart.
  • With temporary content that has 60 characters and 12 titles, run at 4× and record 10 seconds in the Chrome devtools Performance tab. Expected: the FPS chart stays at 60 and no frame is longer than 16.7 ms.
  • In the same recording, look for Long Tasks (over 50 ms). Expected: none.
  • Temporarily wrap one stepMonth call with performance.mark and performance.measure('month', …) (revert afterwards) and read it. Expected: under 16 ms.
  • Grant several titles in a row to characters (landless or not) until a vassal of the Crown holder shows "Unhappy for N months", then let months pass. Expected: after 3 unhappy months a "(rebel)" appears, the Events log reads "… revolts and breaks away", and the Realm outline loses that vassal and his own vassals.
  • Play at mixed speeds until the Crown or the house is lost, or the crown has been held for 60 years. Expected: within about 15 to 20 minutes a "Victory" or "Defeat" banner with the reason appears, the game stops, and actions are disabled.
  • Press R on the game area (or the Restart button). Expected: a fresh dynasty at Jan 1066, no old events, focus on the game area.