Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion godot/autoload/verification_tracker.gd
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,13 @@ var replay_log: Array = []
# producer never emitted the `schedule` key replay_simulator.gd reads).
var event_schedule: Array = []

# ADR-0016 league metabolism: a run belongs to a monthly LEAGUE (the real-world month the
# baseline seed represents). L1 stamps it alongside (seed, game_version) so the artifact
# carries which league it was produced in — cross-version/cross-league boards (DQ-3) read it.
var league_id: String = ""

func start_tracking(seed: String, version: String = "unknown", schedule: Array = []):

func start_tracking(seed: String, version: String = "unknown", schedule: Array = [], league: String = ""):
"""
Initialize verification hash for new game.

Expand All @@ -67,6 +72,7 @@ func start_tracking(seed: String, version: String = "unknown", schedule: Array =
game_seed = seed
game_version = version
event_schedule = schedule.duplicate(true)
league_id = league
tracking_enabled = true
replay_log.clear()

Expand Down Expand Up @@ -159,6 +165,25 @@ func record_event_response(event_id: String, response_id: String, turn: int):
print("[VerificationTracker] Response: %s → %s → %s..." % [event_id, response_id, verification_hash.substr(0, 16)])


func record_window_response(event_id: String, response: String, payment_source: String, turn: int):
"""Update hash + replay log when a player (or auto-resolution) answers a response window
(L1 / ADR-0009 §3). Schema bump: window records carry the PAYMENT SOURCE
(reserve / cannibalize / defer / ignore), the datum the exploit-finder's response-policy
axis reads. `k:"w"` — the v1 replay simulator ignores unknown keys, so this is additive
and pre-L1 artifacts stay verifiable.
Entry: {"t": turn, "k": "w", "ev": event_id, "resp": response, "pay": payment_source}"""
if not tracking_enabled:
return

var data = "%s|window:%s->%s@%s|t%d" % [verification_hash, event_id, response, payment_source, turn]
verification_hash = data.sha256_text()

replay_log.append({"t": turn, "k": "w", "ev": event_id, "resp": response, "pay": payment_source})

if debug_mode:
print("[VerificationTracker] Window: %s -> %s (%s) -> %s..." % [event_id, response, payment_source, verification_hash.substr(0, 16)])


func record_rng_outcome(rng_type: String, value: float, turn: int):
"""
Update hash for significant RNG outcomes.
Expand Down Expand Up @@ -249,6 +274,8 @@ func get_replay() -> Dictionary:
# DQ-6 (#620 item 4): the verifier reads `schedule` (replay_simulator.gd) —
# emit it. [] for unscheduled runs, so pre-L0 artifacts stay verifiable.
"schedule": event_schedule.duplicate(true),
# ADR-0016: which monthly league produced this run, carried beside seed+version.
"league": league_id,
"log": replay_log.duplicate(true)
}

Expand Down Expand Up @@ -277,6 +304,7 @@ func snapshot() -> Dictionary:
"game_seed": game_seed,
"game_version": game_version,
"event_schedule": event_schedule.duplicate(true),
"league_id": league_id,
"replay_log": replay_log.duplicate(true)
}

Expand All @@ -288,6 +316,7 @@ func restore(snap: Dictionary) -> void:
game_seed = snap.get("game_seed", "")
game_version = snap.get("game_version", "")
event_schedule = (snap.get("event_schedule", []) as Array).duplicate(true)
league_id = String(snap.get("league_id", ""))
replay_log = (snap.get("replay_log", []) as Array).duplicate(true)


Expand Down
14 changes: 12 additions & 2 deletions godot/data/balance/defaults.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
{
"_description": "Central gameplay tunables (L9, issue #621). These are the shipped defaults — identical to the pre-L9 inline literals, so this file is behavior-neutral until a sweep varies it. Tune by editing here or by dropping a user://balance_overrides.json with the same shape (deep-merged on top). Consumers pass the same values as call-site fallbacks, so a missing file degrades safely.",
"events": {
"_description": "Event pacing (#568): no events before first_event_turn; at most max_new_events_per_turn new events fire per turn (excess re-roll/re-evaluate later).",
"_description": "Event pacing. first_event_turn/max_new_events_per_turn are the pre-L1 day-tick throttle (#568) — kept for the resolution tick. The MONTH plan layer (ADR-0009/workshop#3) governs decision load via the window DEMAND budget below, not an information budget: only 'window'-tier events demand a decision. Tiers per genre live on the event definitions (delivery_tier: ambient|feed|window).",
"first_event_turn": 2,
"max_new_events_per_turn": 2
"max_new_events_per_turn": 2,
"default_delivery_tier": "feed",
"window_demand_budget": 3,
"window_demand_budget_endgame": 6,
"endgame_turn": 200,
"unanswered_window_rep_penalty": 2.0,
"default_event_class": "deferrable"
},
"attention": {
"_description": "Founder currency (workshop#3 addendum #5 / ADR-0011): ~N decisions per PLAN MONTH; admin is painful overhead spent here. Staff spend a SEPARATE per-person 'actions' currency — there is no global pool (L2 deletes the legacy AP pool; L1 introduces Attention alongside it). Unspent reserve evaporates at month end (ADR-0009 §4 — no banking).",
"per_month": 20
},
"papers": {
"_description": "Paper-decision pacing (#630 stopgap): at most max_decisions_per_turn paper accept/reject decisions surface per turn; clustered decision_turns otherwise flood a click-through wall. Surplus stays UNDER_REVIEW and resolves on later turns (never dropped). 0 = unlimited (pre-#630). Superseded by ADR-0009/0012/0014 response windows.",
Expand Down
69 changes: 64 additions & 5 deletions godot/scripts/core/build_info.gd
Original file line number Diff line number Diff line change
Expand Up @@ -51,17 +51,76 @@ static func get_build_date() -> String:
static func is_dev_build() -> bool:
return DEV_BUILD

## Compact commit+date stamp, e.g. "fd60eb6 · 2026-07-11". Always non-empty:
## falls back to the date, then to "unstamped", so the overlay never shows blank.
# --- Live git identity (L1 follow-up: the stamp went stale and cost a playtest) --------
#
# The baked stamp is written at package time and silently rots in a dev checkout (it read
# "fd60eb6 · 2026-07-11" on every branch for two days). Dev builds now read the REAL HEAD
# from git at runtime; the baked stamp is only the fallback for exported builds (no .git,
# no git binary) and is explicitly marked "(stamp)" so it can never pass as live.
# Acceptance: two different checkouts can never show the same badge silently — live HEAD
# differs per checkout, and a stamped badge visibly says it is a stamp.

## Cache: "" = not probed yet; "-" = probed, git unavailable (don't re-shell every frame).
static var _live_git_cache: String = ""


## Live "branch@shortsha" read from git at runtime, or "" when this is not a git checkout
## (exported build) or git is not installed. Cached after the first probe. Also emits a
## LOUD stale-stamp warning when the baked stamp disagrees with the live HEAD.
static func get_live_git_stamp() -> String:
if _live_git_cache != "":
return "" if _live_git_cache == "-" else _live_git_cache
_live_git_cache = "-"

# Only shell out when this looks like a git checkout: the repo root is one level above
# the godot project dir; .git is a dir in a normal clone and a FILE in a git worktree.
var root := ProjectSettings.globalize_path("res://").path_join("..")
var dotgit := root.path_join(".git")
if not (DirAccess.dir_exists_absolute(dotgit) or FileAccess.file_exists(dotgit)):
return ""

var out: Array = []
if OS.execute("git", ["-C", root, "rev-parse", "--short", "HEAD"], out) != 0 or out.is_empty():
return ""
var sha := String(out[0]).strip_edges()
if sha.is_empty():
return ""

var branch := ""
var bout: Array = []
if OS.execute("git", ["-C", root, "rev-parse", "--abbrev-ref", "HEAD"], bout) == 0 and not bout.is_empty():
branch = String(bout[0]).strip_edges()
if branch == "HEAD":
branch = "detached"
_live_git_cache = ("%s@%s" % [branch, sha]) if not branch.is_empty() else sha

# The baked stamp exists AND disagrees with reality -> say so in the console. The badge
# shows live git regardless, so a stale stamp can never be mistaken for the build
# identity. (print, not push_warning: in a dev checkout the stamp is stale after every
# commit by construction — GUT/CI must not count that as an engine error.)
var stamped := get_commit()
if not stamped.is_empty() and not sha.begins_with(stamped) and not stamped.begins_with(sha):
print("[BuildInfo] NOTE: build_stamp.txt is stale (stamp %s vs live HEAD %s) — badge uses live git" % [stamped, sha])

return _live_git_cache


## Compact build-identity stamp. Dev checkouts: live git, e.g.
## "l1-month-turn-engine@a1b2c3d · live". Exported/no-git: the baked stamp explicitly
## marked "(stamp)", e.g. "fd60eb6 · 2026-07-11 (stamp)". Always non-empty: degrades to
## the date, then "unstamped", so the overlay never shows blank.
static func get_stamp() -> String:
var live := get_live_git_stamp()
if not live.is_empty():
return "%s · live" % live
var commit := get_commit()
var date := get_build_date()
if not commit.is_empty() and not date.is_empty():
return "%s · %s" % [commit, date]
return "%s · %s (stamp)" % [commit, date]
if not commit.is_empty():
return commit
return "%s (stamp)" % commit
if not date.is_empty():
return date
return "%s (stamp)" % date
return "unstamped"

## One-line badge text for the corner indicator, e.g.
Expand Down
105 changes: 89 additions & 16 deletions godot/scripts/core/clock.gd
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,22 @@ static func weeks_to_turns(weeks: int) -> int:

static func months_per_turn() -> float:
"""Calendar months represented by one turn. Fixed at 1.0 until the variable
game-length system lands (see docs/design/TWO_ACT_STRUCTURE.md)."""
game-length system lands (see docs/design/TWO_ACT_STRUCTURE.md).

L1/ADR-0009 note: the resolution tick stays day-grained (turn = 1 workday);
the MONTH is the PLAN cadence, a layer above the tick (MonthPlan / Clock month
helpers below). Badge = calendar date, days-survived scoring stays fine-grained
(ADR-0009 §6). This value is unchanged so risk/doom calibration and recorded
replays are untouched by the month plan layer."""
return 1.0


static func annual_to_per_turn(annual_amount: float) -> float:
"""Convert an annual money magnitude (e.g. salary) to one turn's bill.
Turn = 1 workday, ~260 workdays/yr (#573: was /12, a month billed every day)."""
Turn = 1 workday, ~260 workdays/yr (#573: was /12, a month billed every day).
Billed per workday-turn, ~21.7 workdays/month, so a full month of billing already
sums to ~annual/12 — monthly payroll emerges from the tick grain (ADR-0009
re-denomination is behaviour-neutral here; see MonthPlan doc)."""
return annual_amount / WORKDAYS_PER_YEAR


Expand Down Expand Up @@ -67,21 +76,24 @@ static func date_for_turn(turn: int, start_year: int, start_month: int, start_da
var month = start_month
var day = start_day + total_days

# Roll over months and years
while day > DAYS_IN_MONTH[month - 1]:
# Check for leap year February
var feb_days = 28
# Roll over months and years.
# L0 leap-day fix (#620 note 2): the old loop condition compared `day` against the
# NON-leap DAYS_IN_MONTH[1]=28 while the body used a leap-adjusted month_days=29, so a
# date landing exactly on Feb 29 satisfied the while-condition (29>28) but never
# decremented (29>29 is false) — an infinite hang. The month-turn fiction window
# (2017-2040) crosses six Feb-29 dates, so this was latent→live. Compute the
# leap-adjusted length ONCE and use it in both the test and the decrement.
while true:
var month_days = DAYS_IN_MONTH[month - 1]
if month == 2 and is_leap_year(year):
feb_days = 29

var month_days = DAYS_IN_MONTH[month - 1] if month != 2 else feb_days

if day > month_days:
day -= month_days
month += 1
if month > 12:
month = 1
year += 1
month_days = 29
if day <= month_days:
break
day -= month_days
month += 1
if month > 12:
month = 1
year += 1

@warning_ignore("integer_division")
var quarter = ((month - 1) / 3) + 1
Expand All @@ -99,3 +111,64 @@ static func date_for_turn(turn: int, start_year: int, start_month: int, start_da

static func is_leap_year(year: int) -> bool:
return (year % 4 == 0 and year % 100 != 0) or (year % 400 == 0)


# ============================================================================
# MONTH PLAN LAYER (L1 / ADR-0009)
#
# The MONTH is the plan cadence; the day-turn is the resolution tick beneath it.
# These helpers let the engine detect month boundaries (when a fresh plan phase
# opens and reserve evaporates) and label the badge without any consumer needing
# calendar arithmetic. All derive from date_for_turn — one source of truth.
# ============================================================================

const MONTH_NAMES := ["January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"]
const MONTH_ABBR := ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]

const MONTHS_PER_YEAR: int = 12


static func month_index(turn: int, start_year: int, start_month: int, start_day: int) -> int:
"""Absolute month ordinal (year*12 + month-1) for the turn's calendar date.
Monotonic across year rollover, so `month_index(turn) != month_index(turn-1)`
is the month-boundary test. This is the plan-turn counter (ADR-0009: ~114-270
of these to the loss anchors)."""
var d := date_for_turn(turn, start_year, start_month, start_day)
return int(d.year) * MONTHS_PER_YEAR + (int(d.month) - 1)


static func is_month_boundary(turn: int, start_year: int, start_month: int, start_day: int) -> bool:
"""True when `turn` opens a new calendar month relative to `turn - 1` (a fresh
plan phase / reserve evaporation point). Turn 0 is treated as a boundary (the
first plan phase). Days are physics; the boundary is where routine decisions live."""
if turn <= 0:
return true
return month_index(turn, start_year, start_month, start_day) \
!= month_index(turn - 1, start_year, start_month, start_day)


static func month_ordinal_since_start(turn: int, start_year: int, start_month: int, start_day: int) -> int:
"""0-based count of plan-months elapsed since the run's start month. This is the
league/month plan-turn number that stamps the replay artifact (ADR-0016)."""
var start_idx := start_year * MONTHS_PER_YEAR + (start_month - 1)
return month_index(turn, start_year, start_month, start_day) - start_idx


static func month_name(turn: int, start_year: int, start_month: int, start_day: int) -> String:
var d := date_for_turn(turn, start_year, start_month, start_day)
return MONTH_NAMES[int(d.month) - 1]


static func month_label(turn: int, start_year: int, start_month: int, start_day: int) -> String:
"""Badge label — the exact plan month, e.g. 'March 2034' (ADR-0009 §6)."""
var d := date_for_turn(turn, start_year, start_month, start_day)
return "%s %d" % [MONTH_NAMES[int(d.month) - 1], int(d.year)]


static func annual_to_per_month(annual_amount: float) -> float:
"""Convert an annual magnitude to a single month's bill (annual/12). Provided for
consumers that bill once per plan-month; the per-workday payroll path already sums
to this over a month (see annual_to_per_turn)."""
return annual_amount / float(MONTHS_PER_YEAR)
Loading
Loading