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
transitiveand 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 andrunskips it), SCH-108, SCH-109 (runPhase,runPhasesstep it), SCH-042 (dt = 0), SCH-010 (defineSystem), SCH-012, SCH-013 (same-phase order andafteredges), SCH-022 (order depends only on the arrays), SCH-038 (a phase without systems is a no-op). SCH-140: the schedulenameis passed tocreateSchedule('ck3/month') and no test asserts it (OQ13). TheOnStart, 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
kootaskill andreference/koota): trait,relation({ exclusive, store }),createWorld, world traits throughworld.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 }, andAge { months }. It is living iff it has noDeadtag.ageYears(c) = floor(Age.months / 12). An adult hasageYears ≥ content.adultAge. - A title is an entity with
Title { tier }andName { value }(the packageName, REL-120),tierone of'kingdom' | 'duchy' | 'county'. Its parent is itsChildOftarget. A title's holder istitle.targetFor(HeldBy); the title is vacant if there is none. A character is landless iff it is living and no title hasHeldBy(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 theCrowntag. The player house isScenario.playerHouse('godwin'). - The liege of
cisc.targetFor(VassalOf). The realm ofris the set of characters that reachrunderVassalOf(REL-F02 and REL-F03 definition of "reach"). From F04a it isworld.query(Reaches(r)). livingLiege(world, c)is the first living entity found by followingtargetFor(VassalOf)fromc(a visited set stops a cycle);undefinedif none.- A month is one run of the month pipeline (ECS model).
GameClock.monthis the number of months run since the start (0 at start). The label of monthmis"<Mon> <year>",year = content.startYear + floor(m / 12),MonfromJantoDecbym mod 12:"Jan 1066"form = 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 withcreateWorld()and return it. It MUST add the world traitsGameClock { 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: [] }, andRng(CK3-033), then spawn every title and every character of the content. Called with nocontentit 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]:
createDynastyMUST throw, namingcreateDynastyand the offending entry, and MUST create no world, if the content violates any of: duplicate ids; a title tier whose parent tier is wrong (kingdomhas no parent,duchyhas akingdomparent,countyhas aduchyparent); a title name that is empty or contains/(a/makesgetPaththrow, 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); aholder,liege, parent, or spouse that names an unknown character;crownTitlethat names nokingdomtitle; 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
createDynastyand 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 ascendingentity.id()order (REL-105), andparent(world, title)its parent (REL-101). - CK3-005 [ecs][test]:
HeldByMUST berelation({ exclusive: true })with the title as source and the holder as target, plain koota (no package trait). Content holders MUST becometitle.add(HeldBy(holder))pairs at spawn. A title without a content holder MUST have noHeldBypair (vacant).holderOf(world, title)MUST returntitle.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 returngetPath(world, title)(REL-121):"England"for the kingdom,"England/Mercia/Cheshire"for Cheshire. It MUST throw, naminggetPath, for an entity that is not alive inworld(REL-134). - CK3-007 [ecs][test]:
findTitle(world, path)MUST returnlookup(world, path)(REL-126): the title whose path equalspath, exactly and case-sensitively, orundefined. A relative or empty path, a path with an empty segment (England//Mercia), and an unknown path MUST returnundefined(REL-129). - CK3-008 [ecs][test]: For every title
tof the default contentfindTitle(world, titlePath(world, t)) === t(REL-132). Characters MUST NOT carryName, sofindTitle(world, 'Harold Godwinson')and the name of any other character MUST returnundefined(a character is never a root forlookup, KL7).Nameis added to titles only. - CK3-009 [ecs][test]:
selectTitle(world, title | null)andselectCharacter(world, character | null)MUST setSelection.titleandSelection.characterwithworld.setand returntrue, or returnfalseand change nothing when the argument is not an alive title (resp. character) ofworld. 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
childrenorder (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"), witharia-pressedfor the selected title. Activating it MUST callselectTitle. The text MUST update live when a holder changes (useQueryanduseTrait). - 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
findTitleon every change. A match MUST callselectTitleand 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: typingEngland/Mercia/Cheshireselects Cheshire andMerciafinds 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), andAge { months }(ageYears × 12plus the contentextraMonths, 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 mostcontent.maxCharacters(default 60, brief §6) characters. Characters have noName(CK3-008). - CK3-021 [ecs][test]:
ParentOfMUST berelation()(plain, non-exclusive, noautoDestroy) with the parent as source and the child as target, andVassalOfMUST berelation({ exclusive: true })with the vassal as source and the liege as target. In F02 both are plain koota (thetransitiveinstall ofVassalOfcomes in F04a). Contentparentsandliegeentries MUST becomeParentOf(child)andVassalOf(liege)pairs at spawn. Test: a character that dies keeps itsParentOfpairs 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 theDeadtag. Characters and titles MUST NOT be destroyed in M1. A dead character MUST keep itsParentOfpairs (as source and target) and MUST be shown as dead in every list (CK3-044, CK3-045). - CK3-023 [ecs][test]:
GameClockMUST 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"), andyearOf(content, month)MUST returncontent.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 phasesAge,Vitals,Succession,Opinions,Loyalty,EndChecks, defined bydefinePhasewithdependsOnchained asVitals → [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 isschedule.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 inOpinions,Loyalty, andEndChecks. - 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 monthnis aged before it can die (age/growbeforevitals/death: a character whose age bracket changes this month uses the new age); a king that dies inVitalsalready has its heir holding the crown title whenrunMonths(world, 1)returns (SuccessionafterVitals, 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 returnfalseand change nothing ifScenario.statusis not'ongoing'. Otherwise it MUST run exactly one month (CK3-024) and returntrue. It MUST NOT readGameClock.paused, the accumulator, or any wall clock, so a paused player can step one month.runMonths(world, n)MUST runnmonths with the same state change asncalls ofstepMonth, without readingpausedorstatus, 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 return0ifGameClock.pausedor status is not'ongoing'; otherwise addrealDt × speedtoaccumulator, runstepMonthonce for each fullcontent.secondsPerMonthinaccumulator(subtracting it), up tocontent.maxMonthsPerAdvanceper call, drop any time beyond the cap (leaveaccumulator < secondsPerMonth), and return the number of months run. The month clock is a world singleton advanced by the demo, becauseecs-schedulev0 has no intervals or fixed step (brief §5 notes). - CK3-028 [ecs][test]:
advanceClockMUST throw ifrealDtisNaN, not finite, or negative, before it changes anything.realDtof0MUST be accepted and run no month. - CK3-029 [ecs][test]:
setPaused(world, paused)MUST setGameClock.pausedand returntrue, or returnfalseand change nothing when status is not'ongoing'.setSpeed(world, speed)MUST setGameClock.speedand returntrueiffspeedis incontent.speeds(default 1, 2, 4) and status is'ongoing', elsefalseand 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 toGameClock.monthwithworld.set, once per month, and nothing else. The systemage/grow(Age, afterclock/month) MUST add 1 toAge.monthsof every living character, and MUST NOT change a dead character's age. Both aredefineSystems (SCH-010). - CK3-031 [ecs][test]: Death chance MUST come from content:
deathChance(content, ageYears)is thechanceof the first bracket ofcontent.mortalitywhoseupTois greater thanageYears(starting values: below 16: 0.0003, below 40: 0.0004, below 60: 0.001, below 80: 0.006, else 0.02 per month). The systemvitals/death(Vitals, first) MUST process the living characters in entity-id order and consume exactly onerng.next()for each; a character dies iffnext() < deathChance(…), and dying MUST be adding theDeadtag withcharacter.add(Dead).vitals/deathMUST NOT change anything else in F02 (extended in F03 and F05; relation cleanup is added by the feature that owns each relation: CK3-067 forMarriedTo, CK3-124 forAlliedWithandOpinion; succession cleansVassalOf, 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 readsMath.random.createSeededRng(seed)MUST give a deterministic sequence.createDynasty(content, { seed })MUST install a seededRngwhen given a seed (and a fixed default seed otherwise), and tests MUST be able to install a scriptedRngwithworld.set(Rng, …). - CK3-034 [ecs][test]:
heirOf(world, character)MUST be a pure read returning the living child ofcharacter(a target ofcharacter.targetsFor(ParentOf)) with the greatestAge.months, ties broken by the lowestentity.id(), orundefined. 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 followVassalOfthrough dead characters and return the first living one, orundefined(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 aVassalOfpair, in entity-id order. For a dead characterD, with heirH = heirOf(D)and liegeL = livingLiege(D), the successor isS = H ?? L(it can beundefined). In this order: (1) every title withHeldBy(D), in ascending title id, MUST becometitle.add(HeldBy(S))(an exclusive replace), ortitle.remove(HeldBy(D))whenSisundefined; (2) every living vassalV ≠ HwithVassalOf(D), in id order, MUST becomeV.add(VassalOf(S)), orV.remove(VassalOf(D))whenSisundefined; (3) ifHexists,HMUST becomeH.add(VassalOf(L))whenLexists, elseH.remove(VassalOf('*')); (4) every remainingVassalOfpair withDas source or target MUST be removed; thensyncRulers(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
Rngkills him, andrunMonths(world, 1)MUST leave both titles held by the 12 year old, who hasCrownandRuler. 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 hasCrown. - 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
VassalOfpair). - 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
VassalOfpair of the heir (includingVassalOf(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 characterD: no title hasHeldBy(D), and noVassalOfpair hasDas 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 makeRulerpresent on exactly the living characters that hold a title or that content marksindependent(the foreign ruler Malcolm III, brief §9), andCrownon exactly the living holder of the crown title (at most one character). It MUST be called bycreateDynastyafter spawn and bysuccession/titlesafter each month with a change (and from F04a bygrantTitle). 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 byentity.id():parentsare the characters withParentOf(c)(world.query(ParentOf(c))),childrenarec.targetsFor(ParentOf), andsiblingsare the other children ofc'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 callselectCharacter, witharia-pressedfor the selected one. Age text updates live (useTrait). - CK3-045 [ux][test]: A "Family tree" region for the selected character MUST show
Parents,Siblings, andChildrenas nested lists of names fromfamilyOf, 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×" fromcontent.speeds(aria-pressed). They MUST callsetPaused,stepMonth, andsetSpeed. While status is not'ongoing'they aredisabled. 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 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, an input, or another region MUST NOT run any game-key handler and MUST NOT bepreventDefaulted, so buttons keep nativeEnterandSpaceandTabkeeps its order. The handler MUST ignoreevent.repeat. Keys:Spacetoggles pause (preventDefault),.steps one month,[selects the next lower speed and]the next higher. The game area MUST have focus after mount.dstays the template's theme toggle. On-screen key hints are CK3-F08. - CK3-048 [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. - 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
EventLogin two fresh worlds (brief §7). Dice (Rng) MUST be consumed in this order inside one month: one draw per living character in id order invitals/death, then (from F03) the birth draws (CK3-069); a month in which nobody is eligible consumes only the death draws. Test with a scriptedRngthat 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.monthsof a living character is one more than before, no dead character changed,Crownis 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 anddt(always0, SCH-042), and MUST NOT readDate,performance,requestAnimationFrame, orMath.random(brief §7). Month length is onlycontent.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]:
MarriedToMUST berelation({ exclusive: true }), andcreateDynastyMUST callsymmetric(world, MarriedTo)before it spawns any content (REL-001). Contentspouseentries (one side listed) MUST end as mirrored pairs (REL-005): for every married paira.has(MarriedTo(b))andb.has(MarriedTo(a)). InstallingsymmetricMUST NOT change any other relation (REL-014). - CK3-061 [ecs][test]:
arrangeMarriage(world, a, b)MUST returnResult<'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 aMarriedTopair ('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: botha.has(MarriedTo(b))andb.has(MarriedTo(a))MUST betrueat once (REL-001), each spouse has exactly oneMarriedTopair, and theWidowedtag (CK3-067) MUST be removed from both. - CK3-063 [ecs][test]:
eligibleSpouses(world, c)MUST be a pure read returning the charactersxfor whicharrangeMarriage(world, c, x)would succeed, sorted byentity.id(). It never includesc. - 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
MarriedTopair, andx.has(MarriedTo(y))iffy.has(MarriedTo(x)). - CK3-067 [ecs][test]: Extends CK3-031.
vitals/death, for each character that dies, MUST alsocharacter.remove(MarriedTo('*'))(REL-003, which removes the mirrored pair on the spouse, REL-010 for termination) and, if it had a spouse, addWidowedto the surviving spouse. Aftervitals/deathnoMarriedTopair has a dead end.WidowedMUST stay until the character marries again (CK3-062), and a character that dies MUST loseWidowed, 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 theWidowedtag and no spouse. - CK3-069 [ecs][test]: The system
vitals/birth(Vitals, aftervitals/death, SCH-013) MUST consider, in entity-id order, every living female characterMthat has a living spouse (her husbandF) and is fertile:content.adultAge ≤ ageYears(M) ≤ content.fertileMaxAge(default 40), has fewer thancontent.maxChildren(default 6)ParentOftargets, and the number of characters (living and dead) is belowcontent.maxCharacters. For each it MUST consume onerng.next(); a child is conceived iffnext() < 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 },Houseof the fatherF(the father's house, OQ6), andAge { months: 0 }, and addParentOf(child)on bothMandF. The name MUST be the next unused entry ofcontent.names[sex](index = number of characters of that sex ever spawned, modulo the list length; no draw). A birth MUST NOT changeMarriedTo,VassalOf, orHeldBy. The child is landless and has no liege. - CK3-071 [ecs][test]: Caps MUST hold: at
maxChildrenor atmaxCharactersno birth happens and no draw is consumed for the capped mother. Test with custom content and a scriptedRngthat always conceives. - CK3-072 [ecs][test]: Order: a married woman who dies in
vitals/deaththis month MUST NOT give birth this month (she is not living whenvitals/birthruns, and the widowed father is not considered). Test with a scriptedRngthat kills her. - CK3-073 [ecs][test]:
familyOf(CK3-043) MUST show a newborn in thechildrenof both parents and in thesiblingsof the older children of those parents, and itsparentsMUST be the two. - CK3-074 [ecs][test]: Scenario: with custom content
birthChance: 1and one fertile married couple and a scriptedRng, 3 months give exactly 3 children (allParentOfpairs, 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"(aWidowedcharacter), or"Unmarried"; a select labelled "Spouse to be" listingeligibleSpousesby name; and an "Arrange marriage" button that callsarrangeMarriage. The button MUST bearia-disabledwith 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 byTaband operable withEnterandSpace). - 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
MarriedTopair, noWidowed, and the late husband'sParentOfpairs 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]:
ReachesMUST berelation()(non-exclusive, no store), a dedicated closure that only the package writes (REL-040), andcreateDynastyMUST calltransitive(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 aReachespair 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))iffisReachable(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 returnworld.query(Reaches(ruler))sorted byentity.id()(REL-048), living characters only. It MUST be empty for a character with no vassals, and MUST NOT containruler. 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 ofcfrom the nearest to the highest, by followingtargetFor(VassalOf)(a visited set ends any cycle), and[]for an independent character. ForLeofwine → Gyrth → Haroldit is[Gyrth, Harold].vassalsOf(world, ruler)MUST return the direct vassalsworld.query(VassalOf(ruler))sorted byentity.id(). - CK3-084 [ecs][test]:
titleLiege(world, title)MUST return the holder of the nearest held proper ancestor oftitle(followingparent(world, t), REL-101, skipping vacant ancestors), else the Crown holder, elseundefined. 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 returnResult<'ended' | 'nocrown' | 'title' | 'taken' | 'grantee' | 'loop'>,{ ok: true }iff: status is'ongoing'('ended'otherwise); a Crown holder exists ('nocrown');titleis an alive title of tier'county'or'duchy'('title'); it is vacant or held by the Crown holder ('taken', OQ2);granteeis 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'iffgrantee === LorisReachable(world, L, VassalOf, grantee)istrue(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, andReachesare 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), andsyncRulers(world)(so the grantee becomes aRuler). The closure MUST then be consistent (CK3-081). It MUST NOT write a pair of any other character: the vassals of the grantee keepVassalOf(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 ofgrantTitle(CK3-085), sorted byentity.id(), andgrantableTitles(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 asgrantTitle. - CK3-089 [ecs][test]: Succession loop guard. Extends CK3-036 step 3: the heir
HMUST become a vassal ofLonly ifisReachable(world, L, VassalOf, H)isfalse(REL-020); otherwiseHMUST have noVassalOfpair (independent). Test with a constructed world in whichHis already a (transitive) liege ofL. Then CK3-081 still holds. - CK3-090 [ux][test]: The detail of the selected character MUST show
"Liege chain: Gyrth, Harold Godwinson"(nearest first, fromliegeChain) or"Independent", the direct"Vassals: …"(or "None") fromvassalsOf, and for a character with a non-empty realm"Realm below: N characters"with their names fromrealmOfas 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" listingeligibleGranteesby name, and a "Grant" button that callsgrantTitle. The button MUST bearia-disabledwith 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"whengrantee === 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
VassalOftarget, make each aRuler, put each in the realm of his liege and of the Crown holder, and change theReachesclosure 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)replacingVassalOf(Harold), leave his titles andVassalOf(Edwin)of Morcar unchanged, and put both in the realm of Gyrth and of the Crown holder. TheReachesclosure MUST change by exactly the added pairsEdwin → GyrthandMorcar → Gyrth(Edwin → HaroldandMorcar → Haroldremain, 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 returnup(world, c, VassalOf, Crown)(REL-060): the nearest entity abovecalongVassalOfthat has theCrowntag, orundefined.VassalOfis exclusive, so the walk has one path and "first" is the nearest (REL-063). ForLeofwine → Gyrth → Harold(Harold hasCrown) it returns Harold; for an independent character it returnsundefined(REL-062). - CK3-111 [ecs][test]:
crownOf(world, crownHolder)MUST returnundefined(self is not considered by default, REL-061), andup(world, crownHolder, VassalOf, Crown, { self: true })MUST return him. A dead character MUST giveundefined(it has noVassalOfpair after succession, CK3-041).crownOfMUST NOT change the world. - CK3-112 [ecs][test]: Cross-check with the closure of F04a: for every living character
c,crownOf(world, c) !== undefinediff the Crown holder exists andc.has(Reaches(crownHolder)), and thencrownOf(world, c) === crownHolder. Run over the seeded scenario of CK3-081 after every month. - CK3-113 [ecs][test]:
crownOfMUST 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 aDeadtag, REL-067 throws only for an entity that is not alive inworld), and MUST throw, namingup, for an entity that is not alive inworld. - CK3-114 [ux][test]: The detail of the selected character (CK3-090) MUST also show
"Nearest crown: <name>"fromcrownOf, 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 (noCrowntag exists anywhere), without a reload. - CK3-116 [ecs][test]:
upMUST be used only throughcrownOf: no other helper walksVassalOfupwards looking for a trait (liegeChainandlivingLiegewalk 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]:
AlliedWithMUST berelation()(non-exclusive), andcreateDynastyMUST callsymmetric(world, AlliedWith)before it spawns any content. Contentalliesentries (one side listed) MUST end as mirrored pairs. The secondsymmetricinstall MUST NOT changeMarriedTobehaviour and the other way round (REL-014): marrying two characters adds noAlliedWithpair, and allying two rulers adds noMarriedTopair. - CK3-121 [ecs][test]:
OpinionMUST berelation({ 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 withholder.get(Opinion(target))and written withholder.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 (0when there is no pair), andopinionReasons(world, holder, target)the non-zero fields, in the fixed ordersameHouse,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 callrefreshOpinions(world), which MUST: (1) for each living charactercin id order, define its targets as the Crown holder (if one exists and is notc) and the liege ofc(if any and distinct), andc.add(Opinion(t))for each target that has no pair yet; (2) remove everyOpinionpair ofcwhose target is neither of those; (3) setsameHouse = content.opinion.sameHouseiffc's house equals the target's house, else0; (4) setmarriedIn = content.opinion.marriedIniffchas a spouse whose house equals the target's house, else0. It MUST leavegrantedandpassedOverunchanged, MUST write a pair only if a field differs, and MUST be callable at any time (it is also called bycreateDynastyafter 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/deathMUST alsocharacter.remove(AlliedWith('*'))(REL-003, the pair disappears on the ally too) and remove everyOpinionpair of the dead as source, and every pair that targets the dead (world.query(Opinion(dead)), eachremove(Opinion(dead))). Aftervitals/deathnoAlliedWithorOpinionpair has a dead end. - CK3-125 [ecs][test]: Extends CK3-087. After a successful grant the action MUST, before
syncRulers, ensure the grantee hasOpinion(crown)(addwhen missing) and set itsgranted = 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, setpassedOver = max(passedOver + content.opinion.passedOver, content.opinion.passedOverMin)(starting values: −5 per grant, floor −30), after ensuring the pair. ThenrefreshOpinions. 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
refreshOpinionsMUST NOT recreate a pair removed by CK3-124. After the Crown holder changes (death and succession) the opinions of the new Crown holder start withgrantedandpassedOverat0: the old pairs were removed with the dead ruler (OQ4). - CK3-127 [ecs][test]:
proposeAlliance(world, other)MUST returnResult<'ended' | 'nocrown' | 'ruler' | 'self' | 'allied' | 'refused'>,{ ok: true }iff: status is'ongoing'('ended'otherwise); a Crown holder exists ('nocrown');otheris a livingRuler('ruler');otheris not the Crown holder ('self'); the Crown holder has noAlliedWithpair withother('allied'); andopinionOf(world, other, crown) ≥ content.opinion.allianceMin('refused', starting value 15). On success it MUST callcrown.add(AlliedWith(other))once, so bothcrown.has(AlliedWith(other))andother.has(AlliedWith(crown))aretrue(REL-001), thenrefreshOpinions. A refusal changes nothing. Acceptance is deterministic and consumes norngdraw. - 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 byentity.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 belowallianceMin), accepts it after the widowed Queen Edith of House Godwin is married to him witharrangeMarriage(hismarriedInis then 15), and the alliance then shows on both. No month needs to run between the steps:refreshOpinionsruns 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
Opinionpair 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") fromopinionReasons. 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 callsproposeAlliance, 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]:
LoyaltyMUST be a trait{ lowMonths }(default 0) on every character (spawned with it).RebelMUST be a tag. Both are plain koota. - CK3-151 [ecs][test]: The system
loyalty/check(Loyalty) MUST get its processing order fromcascade(world, [Person, Not(Dead)], VassalOf)(REL-080), the living characters in level order: all characters with no liege first, then their vassals, ordered byentity.id()inside one depth (REL-081, REL-082), a character with a liege after its liege (REL-086). For each charactercin that order that has a liegeL: ifopinionOf(world, c, L) ≤ content.revoltThreshold(starting value −10) thenLoyalty.lowMonths += 1, elselowMonths = 0. A character with no liege is skipped. - CK3-152 [ecs][test]: Revolt. When
lowMonths ≥ content.revoltMonths(starting value 3),cMUST revolt:c.remove(VassalOf('*'))(it breaks away),c.add(Rebel),lowMonths = 0. Its titles stay held by it and its vassals keepVassalOf(c), so they leave the Crown holder's realm with it, and the closure is consistent (CK3-081, REL-043). Test: a duke withlowMonths = 2and low opinion revolts, andcrownOf(duke)isundefinedandcrownOf(his county holder)isundefined(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
lowMonthsMUST be left unchanged (not incremented, not reset). Test: a duke and his count both atlowMonths = 2with low opinion: after one month only the duke is aRebel, and the count'slowMonthsis 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/checkMUST be the one just refreshed in the same month (opinions/refreshruns before it, CK3-156). The Crown holder never revolts (no liege). A vassal withopinion > revoltThresholdfor any month haslowMonthsreset to 0 (three low months must be in a row). - CK3-155 [ecs][test]: Revolt MUST only change
VassalOf,Rebel, andLoyaltyof the revolting character. It MUST NOT destroy, kill, or retitle anyone, and MUST NOT addRebelto 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/checkof the same month, not last month's (OpinionsbeforeLoyalty). - CK3-157 [ecs][test]:
EventLogMUST be an AoS world trait{ entries: Entry[] }, each entry{ id, month, kind, text }withkindone of'birth' | 'death' | 'marriage' | 'alliance' | 'title' | 'revolt' | 'outcome', ids strictly increasing, oldest first. It MUST keep at mostcontent.maxEvents(default 100) newest entries, and appending MUST useworld.setwith a new array.logEvent(world, kind, text)is the only writer. - CK3-158 [ecs][test]:
createDynastyMUST 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 whena.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 theGameClock.monthit happened in. The outcome entry is appended byoutcome/checkitself withlogEvent(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
EventLogworld 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: noonRemoveobserver 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 withdepth 0and no liege, thencascade(world, [Reaches(crown)], VassalOf)(REL-080), each with itsliegeanddepth = liegeChain(…).length. Every entry's liege MUST appear earlier in the list, depth MUST NOT decrease (level order, REL-081), and ties are ordered byentity.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 fromrealmOutlineso 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]:
ScenarioMUST be{ status, reason, crownMonths, goalMonths, playerHouse }(CK3-001).statusMUST be written only byoutcome/check(CK3-164).reasonis''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, setstatus = 'defeat',reason = 'house-extinct'; (2) else if the crown title has no living holder, or the living holder is not of the player house, setstatus = 'defeat',reason = 'crown-lost'; (3) elseScenario.crownMonths += 1, and ifcrownMonths ≥ goalMonths(starting value 720, 60 years) setstatus = 'victory',reason = 'goal'. It MUST append one'outcome'entry withlogEventwhen 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 returnfalse,0, or{ ok: false, reason: 'ended' }when status is not'ongoing'and change nothing.selectCharacterandselectTitlestay allowed.runMonthsis for tests only and is not gated. - CK3-166 [ecs][test]: Defeat MUST be reachable: with a scripted
Rngthat kills the Crown holder in a world where he has no living child and no liege,runMonths(world, 1)ends withstatus = 'defeat',reason = 'crown-lost'in the same month (succession leaves the title vacant,EndChecksafterSuccession). 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)statusbecomes'victory'on the twelfthstepMonth, 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 inWorldProvider. It MUST NOT callworld.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 sameEventLogkindandtextsequence. - CK3-169 [ux][test]: When status is not
'ongoing'the UI MUST show arole="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 (orRon 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 pressR. - CK3-170 [ux][test]: An "Events" region MUST be a
role="log"labelled "Events" witharia-live="polite", listing"<Mon year>: <text>"of every entry, newest first, updating live. It reads onlyEventLog. - CK3-171 [ux][test]: A "Crown" region MUST show
"Crown held: <years> of <goal years> years"(floor(crownMonths / 12)andgoalMonths / 12), the current Crown holder's name or"Crown vacant", and updates live. - CK3-172 [ux][test]: A
RebelMUST be shown as text ("(rebel)") in the Characters list (CK3-044) and in the detail, and a vassal withlowMonths > 0MUST 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 usesrunMonths, 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 onworld.reset()(KL1, KL4). The demo therefore never resets a world and restarts with a new one (CK3-168). MarriedToandAlliedWithare flecsSymmetricrelationships.VassalOfisExclusiveplusTransitive, but transitivity is not applied inside queries:Reachesis a materialized closure andisReachablea function (ecs-relations differences).upandcascadeare functions, not query terms:upreturns the entity that holds the trait,cascadereturns an array in level order (ecs-relations differences, brief decision).ParentOfreplaces flecsChildOffor the family tree because kootaautoDestroyhas no "do not delete" mode for a hierarchy: aChildOfchild dies with its parent, a family tree must not.- Opinion is data on a relation pair (
Opinionstore), 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 withdelta_timeand a time scale. Pipelines for turn-based use are the same pattern asdnd/turn. - Observers are koota
world.onAdd, synchronous and called once per pair direction, not flecs observers with events (CK3-158, CK3-159). There is noonRemoveobserver: 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 andChildOfautoDestroycleanup), 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. Ademo-ck3scenario group in theecs-relationsspec (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.
- 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.
- 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. - Passed over. The brief names "passed over for a title" without a rule. Default: each grant lowers
passedOverby 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. - 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. - 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 throughsameHouseandmarriedInchanges (death, marriage). Alternative: apply grant modifiers to the title's liege. Affects: CK3-123, CK3-125, CK3-151. - 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. - Heir. Default: eldest living child, sex and age ignored, including minors (brief: "eldest living child"). Alternative: adults only. Affects: CK3-034.
- Foreign ruler. Default: Malcolm III is a
Rulerby contentindependent, with no title in the tree, so the tree stays at 1 + 2 + 6 titles (brief CK3-F01) andCrownstays on the single English crown holder. Alternative: a Scotland title outside the England tree (a second root forlookup). Affects: CK3-042, CK3-110, CK3-127. - 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.
- 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.
Nameand characters. Default: characters usePerson, notName, becauselookupscans every named entity without aChildOftarget for its first segment (KL7), and a character called like a title would otherwise be found. Alternative: name characters withNameand aChildOf-free filter. Affects: CK3-008, CK3-020.- 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:
upis 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. - Schedule
nameandsystemsOf.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 withsystemsOf(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. - Restart with a new world.
createDynastyreturns a fresh world and the App swaps it inWorldProvider(CK3-168), so no hook, observer, or schedule state outlives the game andworld.reset()is not used. Alternative:world.reset()and install again (as demo-dnd, DND-034), which needs koota React tests foruseTraiton world traits after a reset (not covered). CK3-168 and CK3-169 are the canary. - 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.
- Single crown. Exactly one title is the crown, so
crownOfand the realm closure agree (CK3-112) andupshows the top of the chain. With severalCrownholders (a later empire tier or a second kingdom)upreturns 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/Cheshirein the input. Expected: the detail saysPath: England/Mercia/Cheshireand the title is selected; typingMerciashows "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
stepMonthcall withperformance.markandperformance.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
Ron the game area (or the Restart button). Expected: a fresh dynasty atJan 1066, no old events, focus on the game area.