Tide Scheduler
Status: Designed — independent review applied
Author: Jeroen + agent panel
Last Updated: 2026-08-26
Independent /design-review: APPROVED WITH CONDITIONS 2026-08-26 — all findings applied same day (H2 recurrence bound restated honestly; B1 signed index ranges; M2 epochIndex diff keys; H1 completion-tick sampling; B2 held-violet ruling; H3/M1/M3/M4 precision; L1–L5 incl. salt constants + RNG pin)
Creative Director Review (CD-GDD-ALIGN): APPROVED 2026-08-26
Implements Pillar: Pillar 1 — The Tide Waits for No One
Spike Reference:prototypes/tide-scheduler-spike-2026-08-25/SPIKE-NOTE.md(contract validated 9/9)
Overview
The Tide Scheduler is the Foundation system that makes Driftlands' world move. It is a pure function — DockedLands(seed, wallClockTime) — that determines, for any moment in real time, which landmasses are docked at the home island's shore sockets, which are approaching, and which are departing. It stores no schedule state: the world's past, present, and future are all computable from the world seed and a clock, which is what lets the game run with no server and lets nothing happen while everyone is offline except time passing. Every other MVP system hangs off it — docked lands are where harvesting, combat, and hauling happen; departure is the deadline that gives them stakes; recurrence is the promise that makes missing a land a story instead of a loss.
Players never interact with the scheduler directly. They experience it as the tide: lands arriving on a learnable rhythm, the shore warming amber before a docking and cooling violet before a departure, and the between-sessions question that drives the group's planning — "what's docking tomorrow?" This GDD defines the tide's cadence and recurrence as rules (per the Creative Director's gate condition: recurrence is Pillar 1's only legal mitigation and cannot stay vague), on top of the deterministic contract validated by the scheduler spike (prototypes/tide-scheduler-spike-2026-08-25/SPIKE-NOTE.md, 9/9 contract tests).
Player Fantasy
The tide is infrastructure the player never touches — the fantasy is **living with it, not controlling it**. Its emotional promise is earned literacy: the quiet competence of someone who reads a system the world treats as chaos. The anchor moment: it's Wednesday night, someone posts a screenshot of the horizon — amber bleeding into the cloudbank at the northeast socket — and types *"tin highlands, tomorrow, before work."* No wiki, no menu. They know, the way a fisherman knows weather from cloud shape, because they've watched that color twenty times and it always meant the same thing next. This is the game's #1 aesthetic (Discovery) applied to the schedule itself, and it is a fantasy that fires every session — most tide moments are one player, logging in alone, glancing at the color of the sky.
Its necessary shadow is legible consequence. Someone logs in Saturday morning and the tin highlands are gone — cooled to violet and departed in the night while everyone slept. The beat of "damn, we missed it" resolves into "it'll come back," and the story told in group chat is fond, not bitter — sailors recounting the storm that scared them, not the one that ruined them. Because players are literate readers of the tide, a missed land is a legible failure ("we knew Thursday was coming and we still missed it"), never an opaque one — which is what keeps Pillar 1's indifference feeling like a living world instead of a punishment. The target emotional arc: **competence → occasional humbling → recurrence restores trust → competence again.**
**Tone constraints this fantasy imposes (binding on all player-facing text and UI):** never punitive language — no "YOU MISSED IT" banners, no loss-framed system messages; departures are stated as world facts, not player failures. And a design test for future tide features: if a proposal optimizes the docking event for spectacle players are expected to gather for (fanfares, forced camera pans, presence bonuses), it serves the wrong fantasy — the communal shore-watch vigil is a key-art and trailer beat, never a mechanic. Rewarding synchronous presence punishes exactly the uneven schedules this game is built for.
Reference anchors: Lineage 2's world-that-moves-without-you (competence over time, not spectacle in the moment); harbor towns — things arriving and leaving on schedules older than you.
Detailed Design
Core Rules
- World time. Tide time
tis UTC seconds (Unix epoch), owned by the host. Clients never derive tide state from their own clocks — the host replicatest— onlyt; its computed results are re-derived locally (ADR-0008, 2026-09-11) — on join and on change; clients may re-evaluate the pure function locally only for visual lead, deriving their local visualtaslastReceivedHostT + monotonicLocalElapsed(a monotonic timer, never wall-clock), reset to the host value on every update — presentation only, never an input to any gameplay decision (review M3, locked 2026-08-26).DateTime.Nowand local timezones never touch tide math (DST-immune by construction). - Monotonic time.
tnever moves backward relative to the save's own history: on load,t = max(lastKnownGoodT, hostUtcNow), withlastKnownGoodTupdated at each evaluation. Clock-set-back (accident or cheat) is thereby closed. Clock-skip-forward is possible, permissive, and documented: in a private friends game, skipping the tide is cheating yourself out of anticipation — no anti-cheat machinery. - The world is computed, never simulated.
DockedLands(seed, t, config)is a pure function — no stored schedule state, no accumulation, no offline ticking. Absence of any length costs exactly nothing to evaluate and produces no intermediate events: after a gap, only current state is diffed against last-seen state (a land that came and went entirely unseen produces no notification — by design, per Pillar 1). - Sockets and the cycle. Each shore socket
shas a phase offsetφ(s)drawn from evenly-spaced slots (C/Napart) and assigned by seed-shuffle — no two sockets ever in phase; simultaneous all-socket emptiness is impossible whenW ≥ C/N— true at locked defaults for N≥2, but NOT structural: tuningWbelowC/N(possible inside the documented safe range) surrenders this invariant. Config validation warns (AC-42b); review M1, locked 2026-08-26. A socket is Docked iff((t − φ) mod C) < W, half-open[φ, φ+W). Starting values (tunable, see Tuning Knobs): dock windowW= 158,400 s (44 h), gapG= 100,800 s (28 h), cycleC= 259,200 s (72 h). *The spike's literal constants (72,000 / 48,000) were placeholders — never copy them into production.* - Recurrence: the global deck (Pillar 1's rule). All dockings, across all sockets, in chronological order, deal from a single seed-shuffled deck of the full template pool (seeded Fisher–Yates per
(seed, roundIndex)). When the deck is exhausted, it reshuffles for the next round. Guarantees, stated as rules: (a) every land docks exactly once per round — expected wait after a miss ≈ one round length (deckSize·C/N), honest worst case just under two ((2·deckSize−1)·C/N, a land dealt first in round r and last in round r+1 — see F8, corrected per review H2); (b) the same land never docks twice in a row (seam rule: deterministic swap if round N+1's first card equals round N's last); (c) two copies of the same land are never docked simultaneously (within-round uniqueness + a seam-overlap constraint on the firstMcards,M = floor(W·N/C)). Deck-counting is a feature: "if the tin highlands just left, it's not next" is exactly the literacy the Player Fantasy promises. - Return state. A returning land keeps its identity — silhouette, landmarks, and biome read as "the land we know" — with resources fully regrown (harvest diffs for departed dockings are dropped by contract) and light variation seeded by
(seed, socket, cycleIndex): node placement, enemy camp positions, minor dressing shift. Variation never alters the recognizable shape. - Purity of selection. Which land docks is a function of
(seed, t, config)and nothing else — never base prosperity, never player state. "Prosperity attracts worse tides" is implemented downstream: enemy strength/count scale at land instantiation (owned by Land Dock/Undock + Enemy AI). The scheduler has no interface to Enemy AI at all — a deliberate non-dependency. - Arrival. For
PreloadLeadTime(default 600 s — see Tuning Knobs) beforeφ, the land is visible offshore drifting toward the socket — cosmetic only: no collision, not enterable, purely client-side presentation layered on the Addressables preload window. Gameplay existence begins exactly atφ. - Departure. At
φ+Wthe land soft-closes: all interaction ends, harvest diffs are final, and any player standing on it is deposited at that socket's authored shorelinedepositPoint(a fixed point per socket, authored with the socket layout — LDU quick-spec Rule 8; Base Building AC-63b validates it stays inside the socket's no-build radius so walls can never strand a deposited player; pinned 2026-08-27, BB re-review CS-2 — was "nearest home shoreline", a runtime nearest-point with no data artifact to validate against) — carried inventory untouched, no damage, no death risk; presentation is tone-safe ("the tide carries you home", never "YOU MISSED IT"). Hard unload follows after a technicalEvictionGrace(~15–30 s) once players are confirmed clear. **Harvesting is legal until the exact undock instant** — the last-tick harvest is intended behavior, not a bug, and no grace period, partial credit, or compensation exists in any form. - Single source of temporal truth. No system other than the Tide Scheduler ever stores or recomputes
φ,C,W, or schedule state. Consumers receive derived pure values only (booleans, floats, IDs). The render/UI layers never receive rawt— making a numeric countdown impossible at the interface level, not just by convention.
States and Transitions
Mechanical states per socket — exactly two; presentation states are derived labels:
| State | Interval | Enterable | Harvestable | Visible |
|---|---|---|---|---|
| Absent | [φ+W, φ+C) | No | No | No (empty water; cosmetic drift-in during final PreloadLeadTime) |
| Docked | [φ, φ+W) | Yes | Yes | Yes |
Presentation sub-states (render-only, derived from progress p = (t−φ)/W; curve ownership: art bible §4.3):
| Label | Window | Meaning |
|---|---|---|
| Arriving (cosmetic) | PreloadLeadTime before φ | Land offshore, drifting in; not enterable |
| Amber ramp → hold | arrival window, then held near-peak | Warm tint per §4.3 |
| Violet ramp | final 40% of the drift-out window before φ+W | Cooling tint per §4.3 |
| Drift-out (cosmetic) | EvictionGrace after φ+W | Non-interactable land visually departing; tint held at full violet (F7, B2 ruling) |
Transitions fire host-side via NextBoundary(seed, t) (compute the next transition instant, schedule one wake-up — no polling); ±5 s event tolerance is acceptable; suspend/wake recovers by fresh evaluation.
Interactions with Other Systems
| System | Consumes from Tide Scheduler | Provides back | Interface owner |
|---|---|---|---|
| Save & Persistence | cycleIndex(socket, t) + current epochIndex for diff keys; validity predicate for reconcile-on-load | Stored diffs (epochIndex, socket, cycleIndex, nodeId) — epoch-qualified per review M2 (Edge Case 14) | Scheduler owns cycleIndex/epochIndex derivation + reconcile predicate; Persistence owns storage format |
| Land Dock/Undock | Docked-edge transitions (instantiate/teardown triggers); templateForDocking (deck output); variation seed (seed, socket, cycleIndex); PreloadLeadTime advance notice | Lifecycle events other systems subscribe to | Scheduler owns transition detection + selection; Dock/Undock owns spawn/despawn execution and seed→layout translation |
| Harvesting | Current cycleIndex (never recomputed independently); Docked gate on harvest legality — re-sampled at the completion tick of every harvest action, never cached from initiation (review H1): multi-tick actions crossing φ+W fail; the scheduler does not interrupt in-flight actions — the consumer checks | Diffs written under scheduler-reported keys | Scheduler exclusively owns cycle math |
| Combat | Docked gate re-sampled at action resolution (same H1 rule); soft-close event — combat discards all in-flight combat state per combat GDD Rule 13 (departure never converts to a death) | — | Scheduler owns the clock; Combat owns combat-state consequences. No other coupling (Enemy AI scaling routes via Land Dock/Undock) |
| Tide-Tint Render | Pre-computed tint floats per land (_TideAmount-ready) — never raw t/φ | — | Scheduler owns ramp progress; render owns color mapping (§4.3) |
| UI/HUD (compass glint) | Same derived floats + Docked booleans per socket | — | UI owns visualization/range culling |
| Enemy AI | Nothing — deliberate non-dependency (spawn scaling routes through Land Dock/Undock) | — | — |
Implementation notes flagged → ADR (not GDD scope): TideConfig as an explicit function parameter sourced from data (enables save-compat versioning of tuning changes); NGO time-replication mechanics; stable template ID space (never hash % poolSize).
Formulas
All schedule math uses System.Math with long (integer UTC seconds) and double (intensities only). Banned in schedule math: System.Random, GetHashCode(), UnityEngine.Mathf, float, transcendental functions. FloorDiv/FloorMod are explicit floor-division (C#'s % truncates toward zero and is wrong for negative operands). Any change to the hash algorithm or its constants is a save-breaking change and must be versioned.
F0. StableHash64 — canonical hash (foundation for all others)
StableHash64(seed, parts...) = SplitMix64Combine(seed, parts)
Pinned to the spike's exact algorithm (prototypes/tide-scheduler-spike-2026-08-25/Program.cs):
h = seed XOR 0x9E3779B97F4A7C15
for each part p in parts (order-significant):
h = h XOR (ulong(p) + 0x9E3779B97F4A7C15 + (h << 6) + (h >> 2))
h = h * 0xBF58476D1CE4E5B9
h = h XOR (h >> 31)
return h
Variables:
| Variable | Symbol | Type | Range | Description |
|---|---|---|---|---|
| World seed | seed | ulong | [0, 2^64) | Fixed at world creation, never changes |
| Hash inputs | parts | long[] | any | Order-significant; callers must always pass ≥1 part |
| Result | h | ulong | [0, 2^64) | Uniform (splitmix64 avalanche) |
Output Range: full ulong, uniform. Callers never pass empty parts (would degenerate to seed XOR constant and let two call sites collide). Example: StableHash64(123456789, [2, 5]) → a fixed 64-bit value, identical on every platform, session, and runtime.
Salt constants (review L1, locked 2026-08-26): the string salts written in F1/F4/F5 are notational aliases for fixed long constants passed as ordinary parts: SALT_SOCKET_OFFSET = 1, SALT_DECK = 2, SALT_RESEAM = 3, SALT_SEAM_OVERLAP = 4, SALT_VARIATION = 5. Salts are never derived from strings at runtime — string encoding is unspecified across runtimes and any change would be save-breaking.
F1. socketPhaseOffset
φ(s) = slotOf(seed, s, N) · (C / N)
slots = [0 .. N-1]
shuffled = SeededFisherYates(slots, StableHash64(seed, ["socket_offset_assignment"]))
φ(s) = shuffled[s] * (C / N)
Variables:
| Variable | Symbol | Type | Range | Description |
|---|---|---|---|---|
| Socket index | s | int | [0, N) | Durable socket ID → index mapping owned outside this formula |
| Socket count | N | int | 1–8 (MVP 1, Tier 1 3–4) | Must evenly divide C (see Tuning Knobs) |
| Cycle length | C | long (s) | 259,200 | Shared by all sockets |
| Result | φ(s) | long (s) | [0, C) | Exact multiple of C/N |
Output Range: [0, C); all offsets distinct (permutation), so no two sockets are ever in phase and simultaneous dock-starts are structurally impossible. Example (Tier 1, N=4): C/N = 64,800. If shuffled = [2,0,3,1]: φ = [129,600, 0, 194,400, 64,800]. MVP (N=1): trivially φ = 0. Constraint: N must divide C evenly — 259,200 = 2⁷·3⁴·5² divides cleanly by 1–6 and 8, never use N=7 without re-tuning C.
F2. dockedState, cycleIndex, progress
dockedState(seed, s, t, config) = FloorMod(t − φ(s), C) < W cycleIndex(s, t, config) = FloorDiv(t − φ(s), C) progress(s, t, config) = FloorMod(t − φ(s), C) / W — defined only while docked
Variables:
| Variable | Symbol | Type | Range | Description |
|---|---|---|---|---|
| World time | t | long (s, UTC) | ≥ 0 | Host-authoritative, monotonic-clamped |
| Phase offset | φ(s) | long (s) | [0, C) | From F1 |
| Dock window | W | long (s) | 158,400 | — |
| Cycle length | C | long (s) | 259,200 | — |
| Docked flag | dockedState | bool | — | Land present and enterable now |
| Cycle index | cycleIndex | long | any — negative before the first cycle boundary (Edge Case 7); monotonic non-decreasing | Diff-key component; never resets within an epoch (Edge Case 14) |
| Progress | progress | double | [0, 1) | 0 = just docked; → 1 = about to undock |
Output Range: dockedState clean boolean; cycleIndex monotonically non-decreasing (monotonic clamp guarantees this). API rule: return (bool docked, double progress) as a pair so no consumer reads progress for an Absent socket. Example: φ=0, t=600,000: 600,000 mod 259,200 = 81,600 < 158,400 → docked, cycleIndex = 2, progress = 81,600/158,400 ≈ 0.515.
F3. Global docking sequence — dockingIndex
dockingIndex(s, cycleIndex_s) = cycleIndex_s · N + slotOf(seed, s, N)
Because offsets are exact multiples of C/N, dock-starts occur globally at every multiple of slotWidth = C/N — one docking every C/N seconds, forever, ties structurally impossible. Inverse direction:
globalSlot(t) = FloorDiv(t, slotWidth)
socketForSlot(g) = inverse(shuffled)[FloorMod(g, N)]
cycleIndexForSlot(g) = FloorDiv(g, N)
dockingIndex = globalSlot
Variables:
| Variable | Symbol | Type | Range | Description |
|---|---|---|---|---|
| Per-socket cycle index | cycleIndex_s | long | any (Edge Case 7) | From F2 |
| Slot width | slotWidth | long (s) | C/N | Time between consecutive global dock-starts |
| Result | dockingIndex | long | any — may be negative in the first C after world-gen (Edge Case 7); strictly increasing | Chronological ordinal of this docking across ALL sockets — the index into the global deck |
Output Range: one integer per C/N-second interval, unbounded (long). Defensive tiebreak (unreachable by construction, stated for future config changes): if two dock-starts ever coincide, lower socket index wins. Example (N=4, shuffled=[2,0,3,1]): global order = socket 1, 3, 0, 2, repeating each cycle. dockingIndex 0 → socket 1 cycle 0; dockingIndex 4 → socket 1 cycle 1.
F4. templateForDocking — the global deck
templateForDocking(dockingIndex) = deck_r[FloorMod(dockingIndex, deckSize)] where r = FloorDiv(dockingIndex, deckSize) and deck_r = ApplySeamRules(SeededFisherYates(pool, StableHash64(seed, [r, "deck"])), r)
Seeded Fisher–Yates — RNG pinned per review L2 (locked 2026-08-26): each draw is an independent hash, `j = FloorMod(StableHash64(seed, [SALT_DECK, r, i]), i + 1). The negligible modulo bias at deckSize ≤ 8` is explicitly accepted. Never System.Random, never a stateful stream:
deck = copy(pool)
for i from deckSize-1 down to 1:
j = FloorMod(StableHash64(seed, [SALT_DECK, r, i]), i + 1) // inclusive
swap(deck[i], deck[j])
Negative rounds (review B1): r and dockingIndex may be negative during the first C after world-gen; all deck math uses FloorDiv/FloorMod and is defined for negative inputs. Seam rules apply identically across the r = −1 → 0 boundary (AC-28b).
Seam rule (a) — no consecutive repeat:
if deck_{r+1}[0] == deck_r[deckSize-1]:
swapIndex = 1 + (StableHash64(seed, [r+1, "reseam"]) mod (deckSize-1))
swap(deck_{r+1}[0], deck_{r+1}[swapIndex])
Seam rule (b) — no simultaneous duplicates across the round seam, over the overlap window M = FloorDiv(W·N, C) (max concurrently-live dockings drawn from adjacent rounds). Deterministic and order-independent:
tail = last M cards of deck_r ; head = first M cards of deck_{r+1}
for each conflict (tail[i] == head[j]) in canonical order (j asc, then i asc):
if still conflicting:
candidates = indices k ≥ M where deck_{r+1}[k] ∉ tail
chosenIndex = candidates[StableHash64(seed, [r+1, "seamoverlap", j]) mod |candidates|]
swap(deck_{r+1}[j], deck_{r+1}[chosenIndex])
Swap targets come from outside the overlap window, so resolving one conflict can never create another within this seam — the pass terminates without retry loops. Precision (review H3, locked 2026-08-26): candidates is recomputed at each iteration against the current mutated deck_{r+1} — a card is never swapped into position twice. Non-emptiness is guaranteed, not assumed: at most M cards of deck_{r+1} positions k ≥ M can appear in tail, so |candidates| ≥ deckSize − 2M ≥ 1 by the strict deckSize > 2M guard (AC-14b asserts this at every iteration). A swap may place the moved card in round r+1's own tail window — that is legal; the next seam's pass handles it under the same guarantee.
Variables:
| Variable | Symbol | Type | Range | Description | ||
|---|---|---|---|---|---|---|
| Template pool | pool | TemplateId[] | MVP 2–3, Tier 1 6–8 | Stable ID space — never hash % poolSize | ||
| Deck size | deckSize | int | ` | pool | ` | Must satisfy deckSize > 2M (guard) |
| Round index | r | long | any — negative during the first C (Edge Case 7) | Which shuffle of the deck | ||
| Overlap window | M | int | FloorDiv(W·N, C) | MVP: 0 (no-op); Tier 1 (N=4): 2 | ||
| Result | templateForDocking | TemplateId | ∈ pool | The land for this docking |
Output Range: always a valid pool member; every template exactly once per round; never the same land twice in a row; never two copies simultaneously docked. Example (Tier 1): M = FloorDiv(158,400·4, 259,200) = 2 — the last 2 cards of round r and first 2 of round r+1 must be disjoint. At MVP M = 0: the check degenerates safely, no special case needed. Guard: deckSize > 2M must hold or seam resolution could run out of candidates; true for all locked configs, stated in Tuning Knobs.
F5. variationSeed
variationSeed(seed, s, cycleIndex) = StableHash64(seed, [s, cycleIndex, "variation"])
Variables:
| Variable | Symbol | Type | Range | Description |
|---|---|---|---|---|
| Socket | s | int | [0, N) | Per-socket — variation is "this land, this visit" |
| Cycle index | cycleIndex | long | any (Edge Case 7) | From F2 (NOT the global dockingIndex) |
| Result | variationSeed | ulong | [0, 2^64) | Handed opaquely to Land Dock/Undock |
Output Range: full ulong, uniform. Distinctly salted from template selection — changing variation logic can never perturb which template docks, and vice versa (stated as a rule, not an accident). The scheduler's contract ends at handing over the seed; layout interpretation is owned downstream. Example: socket 1, cycle 2 → StableHash64(seed, [1, 2, "variation"]).
F6. NextBoundary
NextBoundary(seed, t) = min(nextStart(t), nextEnd(t))
nextStart(t) = (FloorDiv(t, slotWidth) + 1) · slotWidth
nextEnd(t) = min over sockets s of:
e = φ(s) + FloorDiv(t − φ(s), C)·C + W
if e ≤ t: e += C
Variables:
| Variable | Symbol | Type | Range | Description |
|---|---|---|---|---|
| Query instant | t | long (s) | ≥ 0 | — |
| Result | NextBoundary | long (s, UTC) | (t, t + C] | Soonest instant any socket's state flips |
Output Range: strictly > t, bounded by t + C (every socket docks and undocks once per cycle). Drives the schedule-one-wake-up evaluation pattern — no polling. PreloadLeadTime never appears here: preload consumers subtract it downstream (NextBoundary(t) − PreloadLeadTime), keeping the Absent|Docked mechanical contract closed. Example: socket 1 (φ=0) docked at t=600,000 → its nextEnd = 676,800; if the global nextStart = 648,000 (next multiple of 64,800), then NextBoundary = 648,000.
F7. Tint ramp inputs — amberIntensity, violetIntensity
Sub-windows of progress p ∈ [0,1) (new tuning knobs; art bible §4.3 owns the color mapping — the scheduler owns only these floats):
arrivalWindowFrac = 0.15 (first ~6.6 h of W) · driftOutWindowFrac = 0.25 (last 11 h of W — raised from 0.20 per review M4 so visible violet 0.4·frac·W = 4.4 h meets the ≥4 h evening-session guarantee)
// Amber: ramps in over first 60% of the arrival window, holds to its end, then neutral
if p < arrivalWindowFrac:
amberIntensity = Clamp01((p / arrivalWindowFrac) / 0.6)
else: amberIntensity = 0
// Violet: neutral until drift-out begins; ramps 0→1 over the final 40% of the drift-out window
if p ≥ 1 − driftOutWindowFrac:
localP = (p − (1 − driftOutWindowFrac)) / driftOutWindowFrac
violetIntensity = Clamp01((localP − 0.6) / 0.4)
else: violetIntensity = 0
Variables:
| Variable | Symbol | Type | Range | Description |
|---|---|---|---|---|
| Progress | p | double | [0, 1) | From F2; only evaluated while docked |
| Arrival window | arrivalWindowFrac | double (knob) | 0.15 (test 0.10–0.25) | Fraction of W with any amber |
| Drift-out window | driftOutWindowFrac | double (knob) | 0.25 (test 0.15–0.30) | Fraction of W with any violet; visible violet = 0.4·frac·W must stay ≥ 4 h |
| Amber | amberIntensity | double | [0, 1] | Shader-ready (_TideAmount amber) |
| Violet | violetIntensity | double | [0, 1] | Shader-ready (_TideAmount violet) |
Output Range: both clamped [0,1]; ~60% of every docking is tint-neutral (the fantasy's "normal sky" majority). Violet peaks exactly at undock. Absent sockets are never evaluated — with exactly one exception (review B2, locked 2026-08-26): **during EvictionGrace the render layer receives violetIntensity = 1.0 held constant with Docked = false** — the only case a non-Docked socket carries tint, so the departure reads continuously through the drift-out instead of cutting to no-signal at the emotional peak. The API surface is unchanged (AC-27 intact); after teardown, no values. Example: p=0.05 → amber Clamp01((0.05/0.15)/0.6) = 0.556, violet 0. p=0.5 → both 0 (steady, the common case). p=0.99 → violet Clamp01(((0.24/0.25)−0.6)/0.4) = 0.9, nearly full; 1.0 at undock, held through EvictionGrace.
F8. Recurrence bounds — the player-facing guarantee
*(Corrected 2026-08-26 per independent review H2: the previous single bound stated the round length as a worst-case guarantee; the honest worst case is just under two rounds. The deck algorithm is unchanged — spike-validated; a tightening seam rule was considered and rejected to avoid new termination proofs. Revisit at Tier 1 if the tail stings — Open Question 5.)*
roundLength = deckSize · (C / N) — time to deal the full deck once recurrenceWorstCase = (2·deckSize − 1) · (C / N) — land dealt first in round r, last in round r+1 recurrenceExpected ≈ roundLength
Variables:
| Variable | Symbol | Type | Range | Description |
|---|---|---|---|---|
| Deck size | deckSize | int | MVP 2–3, Tier 1 6–8 | — |
| Socket count | N | int | MVP 1, Tier 1 3–4 | — |
| Round length | roundLength | long (s) | > 0 | Expected wait after a miss; the deck is fully dealt once per round |
| Worst case | recurrenceWorstCase | long (s) | > 0 | Hard bound on the wait for any specific land — this is the promise number |
Output Range: both scale as deckSize / N — growing templates and sockets together keeps waits flat-to-improving. Example (MVP, N=1, deckSize=3): roundLength `3 · 259,200 = 777,600 s = 9 days; worst case 5 · 259,200 = 1,296,000 s = 15 days` — the honest player-facing promise: *"a missed land is typically back in about 9 days — never more than 15."* Example (Tier 1, N=4, deckSize=8): roundLength `8 · 64,800 = 518,400 s = 6 days; worst case 15 · 64,800 = 972,000 s = 11.25 days` — Tier 1 has more templates yet recurs faster, because global dockings happen 4× as often.
Edge Cases
Time and clock
- If a player logs in with a saved position on a now-Absent land: placed at that socket's
depositPointbefore the world renders — identical to Rule 9's deposit; inventory untouched, no loss messaging. *The world doesn't distinguish "departed while you watched" from "departed while you were offline"; identical recovery also closes any logout-timing exploit.* - If a harvest completes at
t = φ+W − 1(last legal second): succeeds in full. If the completion tick is≥ φ+W: fails outright — the land no longer exists. Intended behavior, not a bug — the sharp cutoff is the point; never add padding. - If wall-clock advances ≥ 1 full cycle while nobody plays (suspend, absence, closed app): state is computed fresh at the new
t— no backlog replay, no catch-up notifications, no "you missed N lands" summary. A docking that came and went entirely unseen produces **no notification whatsoever**. *The implementation must never iterate elapsed cycles — only evaluatetdirectly.* - If the host's clock is set backward: `t = max(lastKnownGoodT, hostUtcNow)` — tide time is a monotonic ratchet that free-runs off the host clock but can only click forward.
- **If a save's
lastKnownGoodTis in the future relative to the host clock** (save copied from a faster machine): the future timestamp wins and becomes the new floor; the tide neither waits for real time to catch up nor rolls back. Documented as expected behavior — not a bug to investigate. cycleIndex/dockingIndexrange:long; 1000 years of continuous play ≈ 121,000 cycles — overflow is a closed question, do not reopen.- If
t < φ(s)(only possible in the firstCseconds after world creation):cycleIndexis legitimately negative via floor-division anddockedState/progressremain correct. Constraint: no consumer may assumecycleIndex ≥ 0(signed key space from day one). *Passes all testing done ≥ 72 h after world-gen, then breaks on a fresh save — test explicitly neart = 0.*
Player-in-world
- **If a player is mid-transition (swimming/loading between home and land) at
t = φ+W**: resolved by the host's last-known authoritative position at the soft-close instant — on-land → Rule 9 deposit; already home → no action. Client transition animations are never the authority. **⚑ Flagged for the netcode ADR**: under owner-authoritative movement this reads the host's last received position snapshot — confirm when the ADR is written. - If clients briefly disagree with the host on docked state (visual lead): host state is authoritative for all gameplay (harvest legality, entry, diffs) without exception; the client's local evaluation drives only tint/cosmetics and reconciles silently on the next host update — no error, no visible correction event. Actions against a state the host rejects fail like any other authority mismatch, with no tide-specific error messaging.
- If the host disconnects mid-session (session ends; host migration is banned): nothing needs finalizing — diffs are written synchronously per-action, never batched, so everything persisted up to the last evaluated
tis already final. Next session simply re-evaluates under the monotonic clamp. There is no flush step, deliberately. - **If
EvictionGraceexpires with a player still on the soft-closed land** (AFK, alt-tabbed, unresponsive client): server-authoritative force-relocation to that socket'sdepositPoint(Rule 9) fires immediately at expiry — not deferrable, not dependent on client acknowledgment or animation. *The grace window smooths the common case; the hard teleport is the actual guarantee. It is a technical teardown window, never a gameplay grace period.* - If a player tries to reach a cosmetic drift-in land before
φ(swim, projectile, interaction raycast, camera): no collision, not a valid target for any interaction system, not enterable; camera may view it freely. Gameplay existence begins exactly atφ— no earlier partial availability of any kind.
Configuration changes (rulings locked 2026-08-26)
- If the template pool changes mid-save (update adds/removes a land): the in-flight round completes on the old deck; the new pool takes effect at the next round boundary (
TideConfigis versioned). A dealt round never mutates — deck-counting is a feature, and a pool that changes mid-round would break the literacy contract. Harvest diffs are unaffected by construction (keys never reference the pool).recurrenceBoundrecalculates from the next round and is stated to players as a plain fact. - If the socket count
Nchanges mid-save (the MVP → Tier 1 upgrade): hard epoch boundary. All offsetsφ(s)are recomputed via F1 for the newN(evenly-spaced slots redistributed across all sockets — existing sockets' offsets will change, deliberately);dockingIndex/roundIndexreset to 0 at the epoch (referenced to the config-activation timestamp); **anepochIndex— starting at 0, incremented at every config epoch, never reset — qualifies every diff key `(epochIndex, socket, cycleIndex, nodeId)`, so pre-epoch keys can never collide with post-epoch dockings even if a stale diff somehow survives reconcile** (review M2, locked 2026-08-26; AC-35b); old diffs age out through normal reconcile (their dockings are definitionally no longer live); the recurrence guarantee restarts at the epoch. One honest, visible schedule shift at a content update — never silent drift. *The alternative (preserving old offsets, fitting new sockets into gaps) was rejected: it abandons the evenly-spaced-slot guarantee the global-deck math depends on.*
Degenerate configurations (config-load validation, fail loudly)
- If
N = 1(MVP):M = 0— the seam-overlap check degenerates to a no-op with no special-case branch. Formal test boundary. - If
deckSize = 1: the anti-repeat seam rule is unsatisfiable and is skipped entirely — same-land-twice is unavoidable and correct. *Implementation landmine: the swap index divides bydeckSize − 1(division by zero if not guarded). Guard and test even though MVP locks ≥ 2 templates.* - If
W = 0orW ≥ C: invalid config, rejected at load time with a loud error —W = 0yields a socket that can never dock (silent breakage);W ≥ Cyields a permanently docked land (violates Pillar 1's absence requirement). Enforce0 < W < C. - If
arrivalWindowFrac + driftOutWindowFrac > 1.0: both tint ramps would fire simultaneously with no defined blend. EnforcearrivalWindowFrac + driftOutWindowFrac ≤ 1.0at config load (locked values sum to 0.40 — wide margin, but the guard must be explicit; was written as 0.35 before the M4 raise ofdriftOutWindowFrac— corrected 2026-08-27 alongside ADR-0001, matching AC-43 and the Tuning Knobs).
Dependencies
Upstream (this system depends on): none. The Tide Scheduler is Foundation-layer by design — pure, engine-free C# with no gameplay inputs. One soft runtime dependency: the Netcode Session delivers the host's authoritative t to clients (Core Rule 1). The scheduler's math doesn't depend on netcode existing (it runs identically in EditMode tests and a single-player session); only the distribution of t rides the session layer.
Downstream (systems that depend on this one) — hard unless noted; interfaces specified in Interactions:
| Dependent | Hard/Soft | Interface (summary) |
|---|---|---|
| Save & Persistence | Hard | Epoch-qualified (epochIndex, socket, cycleIndex, nodeId) diff keys; reconcile validity predicate |
| Land Dock/Undock | Hard | Transition edges, templateForDocking, variationSeed, preload notice |
| Harvesting | Hard | cycleIndex (sole source), Docked legality gate (re-sampled at completion tick — H1) |
| Combat | Hard | Docked re-sampled at action resolution; soft-close ordering (combat GDD Rule 13) |
| Tide-Tint Render Pipeline | Soft (presentation) | amberIntensity/violetIntensity floats only |
| UI/HUD (compass glint) | Soft (presentation) | Same floats + Docked booleans |
| Crafting & Gear | Soft (timestamp) | cycleIndex asked for craftedAtCycle on the maker's mark — never gating (crafting Rule 5; row added 2026-08-27, cross-GDD W5) |
| Base Building | Soft (static facts) | Socket positions for its F4 exclusion zones; each socket's authored depositPoint (Rule 9) validated by BB AC-63b — no runtime coupling (row added 2026-08-27, cross-GDD W5) |
| Enemy AI | None — deliberate non-dependency | Prosperity scaling routes through Land Dock/Undock |
Bidirectional-consistency obligations: every dependent GDD/quick-spec now exists and lists Tide Scheduler as upstream — verified in the cross-GDD review of 2026-08-27 (status sentence updated same day, W6). Each dependent must consume the interfaces exactly as specified here (in particular: Harvesting must ask for cycleIndex, never recompute it; render/UI must never receive raw t).
Forward references (owned elsewhere, noted here so they aren't lost): whether player-placed structures can exist on drift lands and what departure does to them → Base Building GDD; the host last-known-position snapshot assumption (Edge Case 8) → netcode ADR; TideConfig-as-parameter and config versioning → architecture ADR.
Tuning Knobs
Not tuning knobs (rules, locked by this GDD): the recurrence guarantee (global deck, once per round, no consecutive repeats, no simultaneous duplicates), the monotonic clock, purity of selection, the sharp φ+W cutoff, and the no-compensation ban. Changing any of these is a design change requiring GDD revision + CD review, not a config edit.
| Knob | Default | Safe range | Too low | Too high | Interacts with |
|---|---|---|---|---|---|
W dock window | 158,400 s (44 h) | 24–72 h | Lands miss whole evenings; adults can't visit at all — tide feels punishing | Near-permanent lands; departure stakes evaporate | C, M = floor(W·N/C), tint windows (absolute hours scale with W) |
G gap | 100,800 s (28 h) | 12–48 h | World never breathes; absence loses meaning | Long empty shores at MVP's single socket — dead-world sessions | C = W + G; MVP N=1 feels G directly |
N sockets | 1 (MVP) / 3–4 (T1) | 1–6, 8 — must divide C; never 7 | One-lane world (fine for MVP) | Content sprawl; recurrence outpaces authoring | slotWidth = C/N, M, recurrenceBound; change = hard epoch (Edge Case 14) |
deckSize (pool) | 2–3 (MVP) / 6–8 (T1) | > 2M guard | Hyper-predictable rotation | Recurrence waits grow — missed lands stay gone too long | roundLength = deckSize·C/N (expected, target ≤ ~9 days); recurrenceWorstCase = (2·deckSize−1)·C/N (the promise number, ≤ ~15 days MVP) |
PreloadLeadTime | 600 s | 300–1,800 s | Addressables load risks a hitch at dock | Drift-in land loiters offshore implausibly long | Cosmetic drift-in duration; never in NextBoundary |
EvictionGrace | 20 s | 10–60 s | Teardown races the deposit sequence | Soft-closed land lingers, inviting "why can't I harvest" confusion | Technical only — never a gameplay window |
arrivalWindowFrac | 0.15 | 0.10–0.25 | Amber blink-and-miss | Urgency wallpaper; neutral majority lost | Sum with driftOutWindowFrac ≤ 1.0 |
driftOutWindowFrac | 0.25 | 0.15–0.30 | Short sessions never see the warning | Half the docking reads as "leaving" | Same guard; visible violet = 0.4·frac·W must be ≥ 4 h (4.4 h at defaults — raised from 0.20 per review M4, whose 3.5 h violated this guidance) |
Knob-interaction warnings: W and G tune the game's real-life rhythm — retune them against the friend group's actual schedule after the MVP playtest, not in the abstract. Raising N without raising deckSize makes recurrence very fast and repetitive; raising deckSize without N makes missed lands sting — move them together. Two numbers to watch (F8): expected roundLength = deckSize·C/N (target ≈ ≤ 9 days) and recurrenceWorstCase = (2·deckSize−1)·C/N — the honest promise number (≤ ~15 days at MVP). Additionally, keep W ≥ C/N at any N ≥ 2 or the never-all-empty invariant is surrendered (Rule 4, AC-42b). All values live in TideConfig (data-driven, validated at load per Edge Cases 17–18); tuning changes take effect per Edge Case 13's round-boundary rule.
Visual/Audio Requirements
The scheduler's visual contract is fully owned by art bible §4.3 (tide-state palette curves) and the Tide-Tint Render Pipeline: this system only supplies amberIntensity/violetIntensity floats and the Docked boolean. Visual requirements it imposes: the cosmetic drift-in/drift-out presentations (Rules 8–9) must read as the land sailing, never as a loading state — no progress bars, no spinners at the socket; and no numeric countdown may ever exist (enforced at the API surface, AC-27). Audio (per concept, desirable not MVP-blocking): docking and departure stingers — adaptive, tone-safe (arrival = invitation, departure = farewell, never alarm), triggered off the same transition events Land Dock/Undock consumes; ambient beds may shift subtly with tint state. Threat-land arrival gets no special sting beyond the standard docking sting — danger reads on the land itself (crimson), not in the announcement.
📌 *Asset Spec note: stingers and drift-in presentation become concrete asset lines when /asset-spec runs after the GDD set is complete.*
UI Requirements
One HUD element consumes this system: the compass tide-glint (art bible §7) — per-socket glint driven by the derived floats and Docked booleans, warm glint for arriving/docked, cool for departing. Binding constraints on any UI consuming tide data: never a numeric timer (impossible by API), never punitive copy (Player Fantasy tone rules), departure messaging states world facts only. Open UI question routed to the UI/HUD quick-spec: whether the game offers a soft deck-memory aid (e.g., a log of which lands have appeared this round) or leaves deck-counting to player memory and note-taking — the Lineage 2 anchor leans toward not handing it over, preserving "you learned this."
📌 UX Flag — Tide Scheduler: this system contributes to the compass/HUD. In Pre-Production, run /ux-design for the HUD before writing epics; stories should cite design/ux/hud.md, not this GDD.
Acceptance Criteria
[UNIT] = EditMode NUnit over the pure functions (no engine dependency — this is the project's first mandatory unit-test suite). [INTEGRATION] = NGO session / Unity runtime. [MANUAL] = human judgment. All criteria use locked TideConfig defaults unless the vector specifies otherwise.
Determinism & Purity
- AC-1 [UNIT] GIVEN fixed vector
seed=123456789, parts=[2,5], WHENStableHash64is evaluated, THEN the result equals the hardcoded expectedulong, byte-for-byte, on every platform. - AC-1b [UNIT] GIVEN
StableHash64(seed,[1,2])andStableHash64(seed,[2,1]), WHEN compared, THEN results differ (order-significance); GIVEN emptyparts, THEN the call is rejected by guard, never silently degraded. - AC-2 [UNIT] GIVEN a table of ≥20 hardcoded
(seed, t, config) → (dockedState, cycleIndex, progress, templateForDocking, amberIntensity, violetIntensity)tuples spanning MVP and Tier 1 configs, WHENDockedLandsis evaluated per row, THEN every field matches exactly (integers exact; doubles within 1e-9) — any change to hash constants, floor math, or ramps fails here first. - AC-3 [UNIT] GIVEN any valid input, WHEN
DockedLandsis called 1,000× with identical inputs, THEN output is bit-identical every call (no mutable state). - AC-4 [UNIT] GIVEN the scheduler call graph, WHEN inspected (static analysis/reflection), THEN no path references
System.Random,GetHashCode(),UnityEngine.Mathf,float,DateTime.Now, or transcendental functions. - AC-5 [UNIT] GIVEN negative operands (e.g.,
FloorMod(−5, 3)), WHEN evaluated, THEN true floor semantics hold (= 1, never C#'s truncating−2).
Schedule Correctness
- AC-6 [UNIT] GIVEN
N=4and a fixed seed, WHEN F1 computes all offsets, THEN they form a permutation of{0, C/4, 2C/4, 3C/4}— all distinct, all exact multiples. - AC-7 [UNIT] GIVEN
N≥2, WHENdockedStateis swept across a full cycle, THEN at every instant at least one socket is Docked. - AC-8 [UNIT] GIVEN
φ=0, t=600,000, WHEN F2 is evaluated, THENdocked=true, cycleIndex=2, progress≈0.515(GDD worked example). - AC-9 [UNIT] GIVEN an Absent socket, WHEN the API is queried, THEN
progressis unreadable without first checkingdocked(type-level guard on the returned pair). - AC-10 [UNIT] GIVEN
N=4, shuffled=[2,0,3,1], WHEN F3 maps slots to sockets, THEN the global order is socket 1, 3, 0, 2 repeating (dockingIndex 0→ socket 1 cycle 0;4→ socket 1 cycle 1). - AC-11 [UNIT] GIVEN a full simulated year at any
N≥2, WHEN dock-start instants are enumerated, THEN no two ever coincide.
Deck & Recurrence
- AC-12 [UNIT] GIVEN any round
r, WHEN F4 deals the full round, THEN every pool template appears exactly once. - AC-13 [UNIT] GIVEN ≥50 consecutive round seams, WHEN seam rule (a) applies, THEN
deck_{r+1}[0] ≠ deck_r[last]always. - AC-14 [UNIT] GIVEN Tier 1 config (
M=2), WHEN seam rule (b) applies across ≥50 seams, THEN the lastMof roundrand firstMof roundr+1are always disjoint. - AC-14b [UNIT] GIVEN any valid config, WHEN seam rule (b) runs at any seam, THEN
|candidates| ≥ 1at every iteration — asserted, not assumed (H3 non-emptiness proof). - AC-15 [UNIT] GIVEN MVP config (
M=0), WHEN seam rule (b) evaluates, THEN it is a clean no-op — no throw, no special branch. - AC-16 [UNIT] GIVEN the same
(seed, socket, cycleIndex), WHENvariationSeedis queried twice, THEN results are identical; GIVEN two different cycleIndexes, THEN seeds differ. - AC-17 [UNIT] GIVEN MVP config, WHEN F8 computes the bounds, THEN
roundLength = 777,600 s(9 days) andrecurrenceWorstCase = 1,296,000 s(15 days) exactly. - AC-18 [UNIT] GIVEN Tier 1 config, WHEN F8 computes the bounds, THEN
roundLength = 518,400 s(6 days) andrecurrenceWorstCase = 972,000 s(11.25 days) exactly. - AC-19 [UNIT] GIVEN any specific template, WHEN dockings are tracked, THEN it docks at least once within
recurrenceWorstCaseof any prior appearance. - AC-19b [UNIT] GIVEN ≥50 consecutive rounds, WHEN the maximum observed gap between consecutive appearances of any single template is measured, THEN it is ≤
recurrenceWorstCase(empirical check of the corrected bound). - AC-20 [UNIT] GIVEN
deckSize=1(Edge Case 16), WHEN seam rule (a) evaluates, THEN the check is skipped — no division by zero, no exception; consecutive same-land docking is permitted.
Boundaries & Eviction
- AC-21 [UNIT] GIVEN
twithinPreloadLeadTimebeforeφ, WHEN arrival state is queried, THEN the land is cosmetic-only (no collision, not enterable, not targetable) andDocked=falseuntil exactlyφ. - AC-22 [UNIT] GIVEN harvest completions at
t=φ+W−1vst=φ+W, WHEN legality is checked, THEN the first succeeds in full and the second fails outright — no grace tick in either direction. - AC-22b [UNIT] GIVEN a multi-tick harvest initiated at
t < φ+Wand completing att ≥ φ+W, WHEN legality is evaluated at the completion tick, THEN it fails — initiation-time legality is never cached (H1). - AC-23 [INTEGRATION] GIVEN a player on a land at soft-close, WHEN the transition fires, THEN they are deposited at that socket's authored
depositPoint(Rule 9) — inventory unchanged, zero damage, no death-risk state. - AC-24 [INTEGRATION] GIVEN
EvictionGraceexpired with a player still present (simulated unresponsive client), WHEN expiry fires, THEN server-authoritative relocation happens immediately, independent of client acknowledgment. - AC-25 [UNIT] GIVEN any
t, WHENNextBoundaryis evaluated, THEN the result is strictly> tand≤ t + C(worked example: min(648,000, 676,800) = 648,000). - AC-26 [UNIT] GIVEN a full cycle swept at 1 s resolution using only
NextBoundary-scheduled wake-ups, WHEN transitions are enumerated, THEN every Absent↔Docked transition is captured by exactly one wake-up — none missed, none duplicated. - AC-27 [UNIT] GIVEN the UI/render-facing API surface, WHEN inspected, THEN no signature exposes raw
t/φ/C/W— a numeric countdown is unbuildable from the exposed surface.
Persistence
- AC-28 [UNIT] GIVEN a fresh save with
t < φ(s), WHEN F2 runs, THENcycleIndexis negative via floor division and all outputs remain correct — tested explicitly att=0and just after world-gen. - AC-28b [UNIT] GIVEN
t = 0withφ(s) > 0andN ≥ 2, WHENtemplateForDockingis evaluated, THEN a valid pool member is returned and roundr = −1's seam rules againstr = 0hold (B1 — negative round space). - AC-29 [UNIT] GIVEN a diff written while its land is live, WHEN the save round-trips without departure, THEN the diff survives and reconciles as valid.
- AC-30 [UNIT] GIVEN a diff whose docking has ended, WHEN reconcile-on-load runs, THEN the diff is dropped — resources fully regrown at next appearance.
- AC-31 [UNIT] GIVEN a stale diff at
cycleIndex=Kand a recurrence atK' > K, WHEN the land re-docks, THEN the stale diff never matches the new docking (spike tests 5–7, promoted to production gates). - AC-32 [UNIT] GIVEN
lastKnownGoodTgreater than host UTC (Edge Case 5), WHEN the save loads, THENt = lastKnownGoodT— future value wins, no rollback, no waiting. - AC-33 [UNIT] GIVEN ≥1 full elapsed cycle since last evaluation, WHEN re-evaluated, THEN a single direct evaluation occurs (zero iteration over elapsed cycles) and zero missed-land notifications are generated.
- AC-34 [UNIT] GIVEN a mid-save pool change (Edge Case 13), WHEN queried before the round boundary, THEN the old deck still deals; after the boundary, the new pool applies and
recurrenceBoundrecalculates. - AC-35 [UNIT] GIVEN a mid-save socket-count change (Edge Case 14), WHEN the epoch crosses, THEN all offsets recompute for the new
N, sequence indices reset to the epoch timestamp, and pre-epoch diffs age out via the reconcile predicate — no crash, no resurrection. - AC-35b [UNIT] GIVEN a pre-epoch diff at
(epoch 0, socket 1, cycle 40)and a post-epoch docking that computes to(socket 1, cycle 40), WHEN reconcile runs, THEN the pre-epoch diff never matches — theepochIndexqualifier makes key collision impossible (M2).
Config Validation (load-time, fail loudly)
- AC-36 [UNIT]
N=1→ load succeeds,M=0no-op path taken. - AC-37 [UNIT]
N=7withC=259,200→ load rejected with explicit error (does not divide evenly). - AC-38 [UNIT]
deckSize ≤ 2M→ load rejected (seam resolution could exhaust candidates) — never reaches runtime. - AC-39 [UNIT]
W=0→ load rejected (socket could never dock). - AC-40 [UNIT]
W ≥ C→ load rejected (permanently docked land violates Pillar 1). - AC-41 [UNIT]
0 < W < C→ load succeeds (positive boundary check). - AC-42 [UNIT]
arrivalWindowFrac + driftOutWindowFrac > 1.0→ load rejected. - AC-42b [UNIT]
N ≥ 2withW < C/N→ load emits a documented loud warning (the never-all-empty invariant is explicitly surrendered, not silently lost — M1). - AC-43 [UNIT] locked defaults (sum 0.40) → load succeeds.
Performance (AC-44/45 hard gates — TD ruling 2026-08-27, ADR-0001; AC-46 advisory)
- AC-44 [UNIT] GIVEN ≥10,000 warmed calls at
N≤8, WHENDockedLands+NextBoundaryare profiled, THEN average ≤ 50 µs per combined call, worst case ≤ 200 µs. - AC-45 [UNIT] GIVEN the hot-path functions, WHEN measured via
GC.GetAllocatedBytesForCurrentThread()across ≥10,000 warmed calls, THEN allocation per call is exactly 0 bytes (no boxing, LINQ, or capturing closures). - AC-46 [INTEGRATION] GIVEN a session spanning a full docking transition, WHEN frame time is profiled, THEN the scheduler contributes ≤ 0.1 ms to any single frame and never appears as a named steady-state cost center.
Multiplayer Consistency
- AC-47 [INTEGRATION] GIVEN host and client evaluating with the same replicated
t, WHEN outputs are compared, THENdockedState,cycleIndex, andtemplateForDockingare bit-identical. - AC-48 [INTEGRATION] GIVEN a transient client/host disagreement near a boundary (Edge Case 9), WHEN the next authoritative update arrives, THEN the client reconciles silently — no dialog, no visible correction; rejected actions fail through the generic authority-mismatch path.
- AC-49 [UNIT] GIVEN different locale/culture/thread environments, WHEN
DockedLandsruns on identical inputs, THEN output is identical (noCultureInfo/parsing/thread-affinity sensitivity). - AC-50 [INTEGRATION] GIVEN a client joining mid-cycle, WHEN it receives the host's
tfor the first time, THEN its first local evaluation matches the host's state immediately — no transient desync window. - AC-50b [INTEGRATION] GIVEN a client extrapolating visual
tbetween host updates (Rule 1), WHEN measured, THEN its extrapolatedtnever exceeds the host'stby more than one replication interval plus tolerance, and drives presentation only (M3).
Minimum smoke set (run first)
- AC-2 — fixed-vector determinism: if this fails, nothing downstream is trustworthy.
- AC-8 — canonical F2 worked example.
- AC-13 + AC-14 — seam rules: the mechanical expression of Pillar 1's recurrence promise.
- AC-22 — the sharp cutoff: the most likely target of a well-meaning wrong "fix."
- AC-30 + AC-31 — stale diffs cannot resurrect: the highest-blast-radius failure class (save-durable).
All five are [UNIT], run in seconds, no engine boot — a true pre-commit smoke gate.
Deliberately not criteria (and why)
- "Tint reads as weather, not a progress bar" — perception judgment; becomes a
[MANUAL]visual-evidence item (screenshot + lead sign-off) once the render system exists. - "9 days feels fair" — a playtest question;
W/Gretuning is explicitly deferred to the MVP friend-group playtest. - Edge Case 8's mid-transition rule — would test an assumption, not a contract; add the AC when the netcode ADR fixes the position-snapshot interface.
Open Questions
| # | Question | Owner | Target |
|---|---|---|---|
| 1 | Edge Case 8: host "last received position" snapshot under owner-auth movement — confirm interface | Netcode ADR | After friend-group playtest |
| 2 | ~~Perf budgets AC-44/46 — advisory → hard gate?~~ RESOLVED 2026-08-27 (ADR-0001, confirmed by /architecture-review 2026-08-28): AC-44/45 hard CI gates in the Editor profile, AC-46 advisory. Note: Advance now runs once per host tick (ADR-0001 §4 as amended), so the ±5 s transition tolerance is effectively ±(5 s + 1 tick). | technical-director | closed |
| 3 | Deck-memory HUD aid: in-game round log vs. player memory | UI/HUD quick-spec | Systems Design phase |
| 4 | Do 44 h / 28 h fit the group's real schedule? | MVP friend-group playtest | Post-MVP tuning pass |
| 5 | Recurrence worst case (~2 rounds): add a tightening seam rule when deckSize grows to 6–8, or does the honest 2-round bound play fine? (H2 fix chose honest restatement over new algorithm complexity) | game-designer | Tier 1 planning |