diff --git a/README.md b/README.md index 7164c02a..05696303 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ Founding builds made before anyone lives in the city, and runs with residents of `godot --path .` starts in **first person** at street level. Set `GRIDFALL_VIEW=orbit` to play the orbit city builder with the full HUD described below. -- **First person:** WASD or the arrow keys walk, Shift runs, mouse to look (click to capture the pointer), `]` and `[` change time speed (1x, 2x, 4x, 8x), `T` skips to the next morning, `Esc` pauses, `F1` hides the HUD, `F5` saves, `F9` loads. `F` by a car gets in (W/S throttle and brake or reverse, A/D steer, Space handbrake, V switches chase and driver view) and `F` again gets out once it slows down. `B` opens the build phone: `1`-`6` pick road, homes, shops, industry, a building (mouse wheel picks which) or bulldoze, left click places on the tile ahead (roads and zones take a start and an end click), right click undoes the start. Each order is paid by the district that owns the land, as if its mayor had ordered it. +- **First person:** WASD or the arrow keys walk, Shift runs, mouse to look (click to capture the pointer), `]` and `[` change time speed (1x, 2x, 4x, 8x), `T` skips to the next morning, `Esc` pauses, `F1` hides the HUD, `F5` saves, `F9` loads. `F` by a car gets in (W/S throttle and brake or reverse, A/D steer, Space handbrake, V switches chase and driver view) and `F` again gets out once it slows down. `B` opens the build phone: `1`-`6` pick road, homes, shops, industry, a building (mouse wheel picks which) or bulldoze, left click places on the tile ahead (roads and zones take a start and an end click), right click undoes the start. Each order is paid by the district that owns the land, as if its mayor had ordered it. The AI activity feed (top right) shows each mayor turn, council ruling, Chaos Director pick, grid order and Gazette front page as it lands. A card shows who decided, the model they run on, whether the answer came live, from a replay, from a fallback or from the offline planner, what they did and why. On foot the mayors take their turns spread over the day. A decision with a place raises a light beacon there. `J` cycles the feed between full, brief and hidden, and `G` routes the GPS to the newest decision with a place. The orbit view (`GRIDFALL_VIEW=orbit`): diff --git a/data/feature_flags.json b/data/feature_flags.json index f3ebc702..fd337903 100644 --- a/data/feature_flags.json +++ b/data/feature_flags.json @@ -13,6 +13,7 @@ "overview": {"default": true, "description": "First-person feature: Tab city overview to watch construction sites."}, "drive": {"default": true, "description": "First-person feature: get into parked or stopped cars with F and drive them."}, "phone": {"default": true, "description": "First-person feature: B opens the phone Build app, which places roads, zones and buildings for the mayor who owns the land."}, + "activity": {"default": true, "description": "First-person feature: AI activity feed, decision beacons and map pings showing which mayor or agent decided what, on which model, from which source, and where; mayors take staggered turns through the day on foot. J cycles the feed, G routes to the newest decision."}, "gmi_live": {"default": true, "description": "Kill switch for live GMI Cloud calls. Off: every call uses the replay cache or heuristics, as with GRIDFALL_OFFLINE=1."}, "analytics": {"default": false, "description": "Local product analytics to user://ops/analytics.jsonl (also GRIDFALL_ANALYTICS=1). Nothing is sent anywhere."}, "error_tracking": {"default": false, "description": "Write contextual error records to user://ops/errors.jsonl (also GRIDFALL_ERROR_LOG=1). In-memory error counters are always on."}, diff --git a/docs/api/index.md b/docs/api/index.md index 1bceac12..8e15438c 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -14,10 +14,12 @@ Generated from the `##` doc comments in `scripts/`. Regenerate with `python3 too ## [scripts/ai/](scripts.ai.md) +- [brain_text.gd](scripts.ai.md#scriptsaibrain_textgd): Removes model brand names from CityBrain text: a Gazette page as it comes in, and state restored from saves written before the display names, whose text feeds the prompts and the HUDs - [chatter_minds.gd](scripts.ai.md#scriptsaichatter_mindsgd) (`ChatterMinds`): The voices of the global chat and of face-to-face talk (slice C §3) - [city_brain.gd](scripts.ai.md#scriptsaicity_braingd) (`CityBrain`): Puts GMI model inference on every layer of the city - [gmi_client.gd](scripts.ai.md#scriptsaigmi_clientgd) (`GmiClient`): Async GMI Cloud chat client with JSON extraction, a recorded replay cache, and heuristic fallback - [gmi_image_client.gd](scripts.ai.md#scriptsaigmi_image_clientgd) (`GmiImageClient`): Async poster image client for GMI's hy-image-v3.5-preview (slice D) +- [mayor_turns.gd](scripts.ai.md#scriptsaimayor_turnsgd): Staggered mayor turns (CityBrain.stagger_mayors, on in street mode) - [resident_minds.gd](scripts.ai.md#scriptsairesident_mindsgd) (`ResidentMinds`): Gives every resident a GMI brain ## [scripts/fp/](scripts.fp.md) @@ -32,6 +34,14 @@ Generated from the `##` doc comments in `scripts/`. Regenerate with `python3 too - [road_topology.gd](scripts.fp.md#scriptsfproad_topologygd) (`RoadTopology`): Static road-tile helpers shared by collision, traffic, the directory, the minimap and the streetscape - [world_scale.gd](scripts.fp.md#scriptsfpworld_scalegd): First-person world scale +## [scripts/fp/activity/](scripts.fp.activity.md) + +- [activity_beacons.gd](scripts.fp.activity.md#scriptsfpactivityactivity_beaconsgd): World beacons for the AI activity layer: one per agent key ("mayor:1", "council", "chaos"), a tall light column in the agent's colour over the tile its latest decision touched, with a ring on the ground that pulses while the decision is new and dims once it is old +- [activity_feature.gd](scripts.fp.activity.md#scriptsfpactivityactivity_featuregd): AI activity (integration feature "activity"): the city's AI decisions, visible from the street +- [activity_feed.gd](scripts.fp.activity.md#scriptsfpactivityactivity_feedgd): The AI activity feed (feature "activity"), top right under the compass: a header with the key and whether the city's agents answer live, a line for who is deciding now (or who goes next), then the newest decisions as cards +- [activity_tags.gd](scripts.fp.activity.md#scriptsfpactivityactivity_tagsgd): Floating tags over the AI beacons (feature "activity"): for each beacon in front of the camera and within RANGE, who decided and what, with the distance, pinned above the ground at the beacon (lower when near, so a close beacon's tag stays in view) +- [activity_words.gd](scripts.fp.activity.md#scriptsfpactivityactivity_wordsgd): Plain words for the AI activity layer: what a mayor's turn did ("Zoned 6 homes · Ordered a Solar Farm"), council, chaos and grid rulings, a support agent's model badge, where an answer came from (live, replay or offline), "4 min" ages and compass directions + ## [scripts/fp/audio/](scripts.fp.audio.md) - [ambience.gd](scripts.fp.audio.md#scriptsfpaudioambiencegd): Area ambience (slice I): looping 2D beds on the Ambience bus (traffic hum, park birds, river, the quiet residential bed, night insects and rain), weighted by what lies around the player and by the weather, and cross-faded over CROSSFADE_S diff --git a/docs/api/scripts.ai.md b/docs/api/scripts.ai.md index 8bc70b47..84acf576 100644 --- a/docs/api/scripts.ai.md +++ b/docs/api/scripts.ai.md @@ -4,6 +4,21 @@ [Index](index.md) +## `scripts/ai/brain_text.gd` + +extends `RefCounted` + +Removes model brand names from CityBrain text: a Gazette page as it comes in, and state restored from saves written before the display names, whose text feeds the prompts and the HUDs. No nodes. + +### Constants + +- `D = preload("res://scripts/sim/sim_defs.gd")` + +### Functions + +- `static func debrand_paper(paper: Dictionary) -> void`: De-brands a Gazette front page in place: headline, editorial, and each story's title and body. +- `static func debrand_state(proposals: Dictionary, mayor_state: Array[Dictionary], paper: Dictionary, council: Dictionary) -> void`: De-brands restored brain state in place: proposal authors, mayor posts, thoughts and log entries, the Gazette and the council ruling. + ## `scripts/ai/chatter_minds.gd` class_name `ChatterMinds` · extends `Node` @@ -90,8 +105,14 @@ Puts GMI model inference on every layer of the city. Each in-game day it batches ### Constants - `D = preload("res://scripts/sim/sim_defs.gd")` +- `MayorTurns = preload("res://scripts/ai/mayor_turns.gd")` +- `BrainText = preload("res://scripts/ai/brain_text.gd")` - `MAX_WAIT_MS = 75000` +### Properties + +- `stagger_mayors: bool`: Street mode sets this: the mayors take the day's turns one by one through the day (turns), so a player on foot sees decisions land every few minutes. Off (orbit, tests): all at dawn. + ### Functions - `func setup(p_sim: CitySim, p_client: GmiClient) -> void` @@ -218,6 +239,35 @@ Async poster image client for GMI's hy-image-v3.5-preview (slice D). request() r - `func tick(_dt: float) -> void`: Advances polling, back-off and submit spacing; _process calls tick(delta), tests call it directly. - `static func long_edge(kind: String, size: String) -> int`: Long edge in pixels for a processed image: 1024 for wide (billboard, hoarding) art and the Gazette, 512 for portrait ads, walls and notices. +## `scripts/ai/mayor_turns.gd` + +extends `RefCounted` + +Staggered mayor turns (CityBrain.stagger_mayors, on in street mode). Instead of all five mayors deciding at dawn, they take the day's turns one by one, spread over the first SPAN of the day in an order that rotates daily, so a player on foot sees decisions land every few minutes. A turn still waiting when the day ends runs then (CityBrain._on_day_ended). No nodes. + +### Constants + +- `D = preload("res://scripts/sim/sim_defs.gd")` +- `SPAN = 0.9`: Share of the day the turns are spread over. + +### Properties + +- `due: Array[int]`: Mayors still to take the day's turn, in turn order. +- `day: int`: The day the waiting turns belong to. + +### Functions + +- `func start(p_day: int, staggered: bool) -> bool`: Starts p_day: queues every mayor's turn when staggered. Returns staggered; false leaves the queue empty for the brain to run the mayors at dawn. +- `func take_due(progress: float, staggered: bool, busy: Array = []) -> int`: The mayor whose turn has come at progress (0..1 through the day), taken off the queue, or -1. Not staggered (street mode left mid-day), the next waiting mayor goes at once. A mayor still deciding (busy[mi], e.g. a live call from the day before) keeps the turn and holds the queue. +- `func take_all(busy: Array = []) -> Array[int]`: Every waiting turn whose mayor is free, taken off the queue (the day is ending). A mayor still deciding (busy[mi]) keeps the turn. +- `func next_turn() -> Dictionary`: The next turn: {"mi": mayor index, "at": the day fraction it starts}, or {} when nobody waits. +- `func clear() -> void` +- `func to_dict() -> Dictionary` +- `func from_dict(d: Variant) -> void`: Restores a saved queue; unknown or repeated mayor indices are dropped. +- `static func order(p_day: int) -> Array[int]`: The order of p_day's turns, rotated daily so nobody always goes first. +- `static func at(k: int) -> float`: The day fraction at which the k-th turn starts. +- `static func sim_idle(sim: Object) -> bool`: True while sim has no sliced job or spread work in flight (always for a synchronous sim). A staggered turn starts only then: its actions come from outside any job, and a mutator from outside finishes the job in flight, and the spread work it waits for, in that one frame. + ## `scripts/ai/resident_minds.gd` class_name `ResidentMinds` · extends `Node` diff --git a/docs/api/scripts.fp.activity.md b/docs/api/scripts.fp.activity.md new file mode 100644 index 00000000..5a6050c1 --- /dev/null +++ b/docs/api/scripts.fp.activity.md @@ -0,0 +1,168 @@ + + +# `scripts/fp/activity/` + +[Index](index.md) + +## `scripts/fp/activity/activity_beacons.gd` + +extends `Node3D` + +World beacons for the AI activity layer: one per agent key ("mayor:1", "council", "chaos"), a tall light column in the agent's colour over the tile its latest decision touched, with a ring on the ground that pulses while the decision is new and dims once it is old. Visual only: no collision, hidden in photo mode by the feature. + +### Constants + +- `WS = preload("res://scripts/fp/world_scale.gd")` +- `BEAM_H = 110.0 * WS.M` +- `BEAM_R = 0.9 * WS.M` +- `RING_R = 7.0 * WS.M` +- `RING_W = 0.6 * WS.M` +- `FRESH = 30.0`: Seconds a beacon pulses as new, and after which it settles to its dim strength. +- `STALE = 480.0` +- `BEAM_SHADER = """` + +### Functions + +- `func place(key: String, pos: Vector3, col: Color, fresh: bool = true) -> void`: Puts the beacon for key at pos (feet on the ground) in col; fresh restarts its pulse. +- `func remove(key: String) -> void` +- `func clear() -> void` +- `func has_beacon(key: String) -> bool` +- `func position_of(key: String) -> Vector3` +- `func keys() -> Array` +- `func freshness(key: String) -> float`: 0..1: how new the beacon is (1 just placed, 0 once FRESH has passed). + +## `scripts/fp/activity/activity_feature.gd` + +extends `FpFeature` + +AI activity (integration feature "activity"): the city's AI decisions, visible from the street. Every mayor's turn, council ruling, Chaos Director pick, grid order and Gazette front page lands as a card in the feed (top right) that names who decided and the model they run on, whether the answer came live, from a replay, a fallback or the offline planner, what they did and why, and where. A decision with a place also raises a light beacon there (tagged when the hero is near) and pings the compass and minimap in the agent's colour. In street mode the mayors take their turns spread over the day (CityBrain.stagger_mayors), so decisions keep landing while you walk. J cycles the feed (full, brief, hidden); G routes the GPS to the newest decision with a place. + +### Constants + +- `D = preload("res://scripts/sim/sim_defs.gd")` +- `WS = preload("res://scripts/fp/world_scale.gd")` +- `S = preload("res://scripts/fp/ui/hud_style.gd")` +- `W = preload("res://scripts/fp/activity/activity_words.gd")` +- `FEED = preload("res://scripts/fp/activity/activity_feed.gd")` +- `BEACONS = preload("res://scripts/fp/activity/activity_beacons.gd")` +- `TAGS = preload("res://scripts/fp/activity/activity_tags.gd")` +- `CITY_HALL = Vector2i(15, 14)`: Where the council sits when no City Hall tile is found (CitySim.new_city builds it here). +- `KEEP = 12`: Decisions kept for the save; the feed shows the newest FEED.MAX_CARDS. +- `REFRESH = 0.25` +- `HERE = 0.75`: Within this many tiles the place line reads "here" instead of a distance. +- `TICKER_H = 240.0`: Height of the chat ticker strip at the bottom of chat_slot (the last lines while it is closed). +- `SAVED_TEXT = [ "key", "agent", "who", "model", "source", "headline", "short", "detail", "extra" ]` + +### Properties + +- `items: Array[Dictionary]`: Oldest first: {"key" ("mayor:" or the support role), "agent" ("" or the role), "who", "model", "source" (live|cache|heuristic), "failed" (a live call was tried and failed), "headline", "short" (its first phrase), "detail" (why), "extra" (votes or a refusal), "color": Color, "tile": Vector2i (D.NO_TILE without a place), "placed": bool, "day", "clock" (feature seconds when it landed; -1 once restored from a save)}. + +### Functions + +- `func feature_id() -> String` +- `func setup(p_ctx: FpContext) -> void` +- `func teardown() -> void` +- `func to_dict() -> Dictionary` +- `func from_dict(d: Dictionary) -> void` +- `func self_test() -> Dictionary` +- `func cycle_feed() -> String`: J: full -> brief -> hidden -> full. Returns the new mode ("" without a HUD). +- `func route_to_newest() -> bool`: G: routes the GPS to the newest decision with a place. False when there is none or no map. +- `func newest_placed() -> Dictionary`: The newest decision with a place, or {}. +- `func where_of(it: Dictionary) -> String`: The open card's place line: "Maple Street · 240 m NE", or the mayor's district ("Citywide" for the support agents) for a decision without a place. + +## `scripts/fp/activity/activity_feed.gd` + +extends `Control` + +The AI activity feed (feature "activity"), top right under the compass: a header with the key and whether the city's agents answer live, a line for who is deciding now (or who goes next), then the newest decisions as cards. The newest card is open: who decided (in their colour) with the model badge, what they did, why (their private thought or ruling), any votes, and a footer with where the answer came from (live, a replay, a fallback or the offline planner) and where it happened. Older cards fold to who and what. J cycles full (MAX_CARDS), brief (the newest card) and hidden (the header only). + +### Constants + +- `S = preload("res://scripts/fp/ui/hud_style.gd")` +- `W = preload("res://scripts/fp/activity/activity_words.gd")` +- `WIDTH = 360.0` +- `INNER = WIDTH - 27.0`: Text width inside a card: WIDTH less the 12 px side margins and the 3 px stripe. +- `SAFE = 24.0` +- `TOP = 118.0`: Under the compass strip (it ends at y 106 in the 1600x900 base). +- `MAX_CARDS = 4` +- `FADE = 0.25` +- `FLASH = 1.6` +- `AGO_W = 44.0`: Room kept at the end of a card's top row for its age ("12 min"). +- `MODES = ["full", "brief", "hidden"]` +- `OPEN_BG = Color(0.03, 0.04, 0.07, 0.74)` +- `FOLDED_BG = Color(0.03, 0.04, 0.07, 0.52)` +- `TAG_COLORS = {"LIVE": S.GOOD, "REPLAY": S.ACCENT, "FALLBACK": S.RED, "OFFLINE": S.AMBER}` +- `STATES = { "live": ["Live", S.GOOD], "down": ["Reconnecting", S.RED], "offline": ["Offline", S.A...` + +### Functions + +- `func setup() -> void` +- `func set_state(state: String) -> void`: Whether the city's agents answer from live model calls: "live", "down" (live calls are failing, so fallbacks answer) or "offline". +- `func state() -> String` +- `func set_line(t: String, pulse: bool) -> void`: The line under the header: who is deciding now (pulse) or who goes next; "" hides it. +- `func line_text() -> String` +- `func add_item(item: Dictionary, fresh: bool = true) -> void`: Adds a card for item (fields in ActivityFeature.items) on top and folds the one below. A fresh card fades in and flashes in its colour; a restored one (from a save) appears as it is. +- `func clear() -> void`: Drops every card (load, New City). +- `func refresh(where_of: Callable, now: float) -> void`: Refreshes every card's age and the open card's place line: where_of(item) -> String; now is the feature clock in seconds. +- `func cycle_mode() -> String`: J: full -> brief -> hidden -> full. +- `func set_mode(m: String) -> void` +- `func headlines() -> PackedStringArray`: Headlines of the cards on screen, newest first. +- `func open_card() -> Control`: The newest (open) card's panel, or null. +- `func card_count() -> int`: How many cards the feed holds (shown or not). +- `func column_rect() -> Rect2`: The header, line and cards as they sit on screen (HUD coordinates). + +## `scripts/fp/activity/activity_tags.gd` + +extends `Control` + +Floating tags over the AI beacons (feature "activity"): for each beacon in front of the camera and within RANGE, who decided and what, with the distance, pinned above the ground at the beacon (lower when near, so a close beacon's tag stays in view). Nearest first; a tag that would leave the screen, cover a HUD area the caller keeps clear or overlap a placed tag is left out instead of being pushed to the edge. Hidden with the HUD, under modals and in photo mode. + +### Constants + +- `S = preload("res://scripts/fp/ui/hud_style.gd")` +- `WS = preload("res://scripts/fp/world_scale.gd")` +- `RANGE = 30.0`: Tiles (world units) within which a beacon gets a tag. +- `LIFT = 14.0 * WS.M`: Height of a tag over its beacon's foot: LIFT_PER of the distance, between LIFT_MIN and LIFT. +- `LIFT_MIN = 2.5 * WS.M` +- `LIFT_PER = 0.3` +- `MAX_TAGS = 4` +- `SAFE = 24.0` +- `GAP = 4.0` + +### Functions + +- `func setup() -> void` +- `func show_tags(entries: Array, cam: Camera3D, me: Vector3, avoid: Array[Rect2] = []) -> void`: entries: [{"key", "pos": Vector3 (beacon foot), "who", "what", "color": Color}], me: the player's position, avoid: rects (in this control's coordinates) no tag may cover. Places up to MAX_TAGS tags for cam; [] or a null cam hides them. +- `func shown() -> Array[String]`: Keys of the beacons tagged on screen now (tests). + +## `scripts/fp/activity/activity_words.gd` + +extends `RefCounted` + +Plain words for the AI activity layer: what a mayor's turn did ("Zoned 6 homes · Ordered a Solar Farm"), council, chaos and grid rulings, a support agent's model badge, where an answer came from (live, replay or offline), "4 min" ages and compass directions. No nodes. + +### Constants + +- `D = preload("res://scripts/sim/sim_defs.gd")` +- `ZONE_WORDS = { "R": ["home", "homes"], "C": ["shop lot", "shop lots"], "I": ["factory lot", "factory...` +- `DIRS = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]` +- `SOURCE_TAGS = {"live": "LIVE", "cache": "REPLAY"}` +- `MAX_PARTS = 2`: Most action phrases one summary lists before "+N more". +- `DETAIL_CHARS = 150` + +### Functions + +- `static func mayor_model(mi: int) -> String`: The model a mayor runs on, as its badge reads ("DeepSeek-V4"). +- `static func model_label(model_id: String) -> String`: A model id as a badge: the part after the vendor, without a trailing date stamp ("openai/gpt-6-luna" -> "gpt-6-luna", "deepseek-ai/DeepSeek-V4-Flash-0731" -> "DeepSeek-V4-Flash"). +- `static func source_tag(source: String, failed: bool = false) -> String`: Where an answer came from: "LIVE" (a model call), "REPLAY" (a recorded model answer), "OFFLINE" (the built-in planner, no model involved) or, when a live call was tried and failed (failed), "FALLBACK". +- `static func summarize(applied: Array) -> String`: One line for what a mayor's turn did, from CityBrain's applied list ([{"action": Dictionary, "ok": bool, "reason": String}]). +- `static func first_refusal(applied: Array) -> String`: The first refusal reason in an applied list ("cannot afford road ($120)"), or "". +- `static func action_words(a: Variant) -> String`: A short phrase for one mayor action ({"type": "zone", "zone": "R", "w": 3, "h": 2} -> "Zoned 6 homes"), or "" for an unknown one. +- `static func politics(entry: Dictionary) -> String`: Proposals and votes from a decision entry ("Proposed the Curfew ordinance · Voted no on Clean Air Act"), or "". +- `static func council_words(r: Dictionary) -> String`: What a council ruling did ("Passed Curfew · Repealed Clean Air Act"). +- `static func chaos_words(d: Dictionary) -> String`: What the Chaos Director chose ("Set off a building fire" or "Kept the city calm"). +- `static func operator_words(d: Dictionary) -> String`: Which district the Grid Operator powers first ("Power goes first to Central Plaza"). +- `static func detail(t: String) -> String`: Free text for a card: de-branded, one line, at most DETAIL_CHARS with an ellipsis. +- `static func tile_of(v: Variant) -> Vector2i`: A saved focus tile back as a Vector2i: CityBrain keeps a Vector2i, a JSON save turns it into "(x, y)" or [x, y]. D.NO_TILE when there is none. +- `static func direction(a: Vector3, b: Vector3) -> String`: Compass direction from a to b on the ground ("NE"), north being -Z. +- `static func ago(seconds: float, day: int) -> String`: A card's age: "now", "4 min", "2 h"; "Day 7" for one restored from a save (seconds < 0). diff --git a/docs/api/scripts.fp.md b/docs/api/scripts.fp.md index f1f73473..16c3e538 100644 --- a/docs/api/scripts.fp.md +++ b/docs/api/scripts.fp.md @@ -200,7 +200,7 @@ Signals that cross first-person slices. Emitters and listeners are listed in doc ### Signals -- `ping(world_pos: Vector3, reason: String)`: A world point that deserves attention (y = 0). reason: "event:", "resident:", "tile", "district:" or "player" (a waypoint set on the map). +- `ping(world_pos: Vector3, reason: String)`: A world point that deserves attention (y = 0). reason: "event:", "resident:", "tile", "district:", "ai:" (an AI decision landed there) or "player" (a waypoint set on the map). - `toast(text: String, kind: String)`: A player-facing notice. kind: "info", "alert", "success" or "system". - `interact_target_changed(target: Dictionary)`: The interaction target under the crosshair changed; {} when nothing is targeted. - `interact_requested(target: Dictionary)`: The player pressed interact on target. diff --git a/docs/api/scripts.fp.ui.md b/docs/api/scripts.fp.ui.md index 019864d7..c109046d 100644 --- a/docs/api/scripts.fp.ui.md +++ b/docs/api/scripts.fp.ui.md @@ -401,6 +401,7 @@ Shared look of the on-foot HUD (slice B): colour tokens, fonts with tabular figu - `WS = preload("res://scripts/fp/world_scale.gd")` - `OWNER_META = "fp_owner"`: Every Label, RichTextLabel and Button slice B creates carries this meta (the branding test reads it). - `OWNER = "hud_widgets"` +- `BADGE_META = "fp_model_badge"`: Meta on model and source badges (badge()): the only street text allowed to name a model. - `WHITE = Color(1, 1, 1)` - `TEXT = Color(0.96, 0.97, 0.99)` - `TEXT_DIM = Color(0.86, 0.9, 0.96, 0.66)` @@ -434,6 +435,8 @@ Shared look of the on-foot HUD (slice B): colour tokens, fonts with tabular figu - `static func caps_label(t: String, size_px: int, col: Color = TEXT, outline: int = 2) -> Label`: Tracked capitals drawn from t; the Label keeps t as its text (only the drawing is upper-cased). - `static func caps_width(t: String, size_px: int) -> float`: Width of t drawn by caps_label at size_px. - `static func stripe(sb: StyleBoxFlat, col: Color, w: int = 3) -> StyleBoxFlat`: Turns sb's border into a single w px accent stripe down its left edge. +- `static func badge(t: String, col: Color, size_px: int = 11) -> PanelContainer`: A small tag that names a model or where an answer came from ("DeepSeek-V4", "LIVE"). Its label carries BADGE_META instead of OWNER_META: badges are the one place street text names a model, so the no-branding check over owned text (owned_texts) leaves them out. +- `static func ping_color(reason: String) -> Color`: Marker colour for a bus ping reason: incidents red, AI decisions in the deciding agent's colour ("ai:1" a mayor, "ai:council" a support agent), anything else amber. - `static func box(bg: Color, radius: int, h: int, v: int, border: Color = Color(0, 0, 0, 0), bw: int = 0) -> StyleBoxFlat`: A StyleBoxFlat with one corner radius and content margins (h, v). - `static func chip_box(h: int = 11, v: int = 4) -> StyleBoxFlat`: The backing behind a passive indicator chip: at most CHIP_BG, near-square, with a hairline along its bottom edge (own instance, so a chip can pulse and tint its own backing). - `static func panel(bg: Color = PANEL_BG, radius: int = RADIUS_PANEL, h: int = 14, v: int = 10, border: Color = PANEL_LINE) -> PanelContainer`: A PanelContainer with a crisp, optionally bordered background (restyled CityHud._panel). diff --git a/docs/fp/ARCHITECTURE.md b/docs/fp/ARCHITECTURE.md index 647489cb..02f437a3 100644 --- a/docs/fp/ARCHITECTURE.md +++ b/docs/fp/ARCHITECTURE.md @@ -96,7 +96,8 @@ Camera ownership: A owns every camera behaviour (follow, collision, V toggle, th - `city_sim.gd`: `var day_seconds: float = SimDefs.BASE_DAY_SECONDS`, read wherever `BASE_DAY_SECONDS` is used today. - `city_brain.gd` and `sim_defs.gd`: `MAYORS[i]["display"]` (human name). The display name replaces the model name in prompts, `city_log`, `owner_name()` and inspector text. `model` stays for the API. The offline Gazette editorial and the inspector prompt stop saying "AI mayors". - S0's own FP files also carry shared contracts that slices build on: `GameBus.card_action`, `CityDirectory.planned_name`, `CollisionWorld.boxes()`, `AP.load_scene()` and the GLB node-name prefixes, `FpPlayer.photo_mode`, and the `fp_kit` test context. S0 §23 (D-S0-17 to D-S0-22) lists them. -- `project.godot`: FP input actions (`move_forward`, `move_back`, `move_left`, `move_right`, `sprint`, `crouch`, `jump`, `interact`, `chat_open`, `map_toggle`, `pause_menu`, `time_faster`, `time_slower`, `time_skip`, `photo_mode`, `hud_toggle`, `quick_save`, `quick_load`) and, for D13, the camera actions `view_toggle` (V), `shoulder_swap` (Q), `zoom_in` / `zoom_out` (mouse wheel up / down) and `look_left` / `look_right` / `look_up` / `look_down` (gamepad right stick, dead zone 0.15). The existing `camera_*` actions stay. The integration overview adds `overview_toggle` (Tab, gamepad Back), `site_next` (N), `site_prev` (B), `site_go` (F) and `site_waypoint` (G). Driving adds `vehicle_enter` (F, gamepad Y; on foot it only takes the press with a car in reach, so Y still opens the map elsewhere), `drive_throttle` (right trigger) and `drive_brake` (left trigger); on the keyboard the move keys drive. The phone adds `phone_toggle` (B); while it is open it reads 1-6, the mouse buttons and the wheel itself. +- `project.godot`: FP input actions (`move_forward`, `move_back`, `move_left`, `move_right`, `sprint`, `crouch`, `jump`, `interact`, `chat_open`, `map_toggle`, `pause_menu`, `time_faster`, `time_slower`, `time_skip`, `photo_mode`, `hud_toggle`, `quick_save`, `quick_load`) and, for D13, the camera actions `view_toggle` (V), `shoulder_swap` (Q), `zoom_in` / `zoom_out` (mouse wheel up / down) and `look_left` / `look_right` / `look_up` / `look_down` (gamepad right stick, dead zone 0.15). The existing `camera_*` actions stay. The integration overview adds `overview_toggle` (Tab, gamepad Back), `site_next` (N), `site_prev` (B), `site_go` (F) and `site_waypoint` (G). Driving adds `vehicle_enter` (F, gamepad Y; on foot it only takes the press with a car in reach, so Y still opens the map elsewhere), `drive_throttle` (right trigger) and `drive_brake` (left trigger); on the keyboard the move keys drive. The phone adds `phone_toggle` (B); while it is open it reads 1-6, the mouse buttons and the wheel itself. The AI activity feed adds `ai_feed` (J, keyboard only: every pad button is taken on foot) and reuses `site_waypoint` (G) on foot to route to the newest decision; the pad's Y keeps opening the map. +- `city_brain.gd` (AI activity): `stagger_mayors` (the activity feature sets it on foot, and teardown restores it). When it is on, `_on_day_started` queues the day's mayor turns in `turns` (`scripts/ai/mayor_turns.gd`, in an order that rotates daily) instead of running them at dawn. `_process` starts the k-th turn once `sim.day_progress()` reaches `MayorTurns.at(k)`, which spreads the turns over the first 90% of the day, and only while no sliced job or spread work is in flight (`MayorTurns.sim_idle`), so its actions never force a synchronous finish. `_on_day_ended` runs any turn still waiting; a mayor still deciding keeps the turn and takes it in `_day_gate` once free. `turns.next_turn()` reads the queue, and saves keep it under `turns` (`due` and `day`). Decision entries also carry `proposal` (the ordinance proposed this turn) and `votes` (the votes cast this turn). `GameBus.ping` reasons starting `ai:` are coloured by `hud_style.ping_color()` on the minimap and compass, and `FpHud` does not toast them. - `FpMode.DEFAULT_SETTINGS` (D13): `camera_mode: "third"`, `tps_fov: 60.0`, `tps_distance_m: 3.2`, `shoulder: 1` (right; -1 left), `auto_shoulder: false`, `hero: "m"` (or `"f"`). `"fov"` stays the first-person FOV. `apply_settings(patch, persist := true)`: `persist = false` changes the settings for this run without writing `user://gridfall_fp_settings.json` (the photo tour and `fp_bench` use it). ## 5. Slices (built in parallel after S0) @@ -118,6 +119,7 @@ Camera ownership: A owns every camera behaviour (follow, collision, V toggle, th | **Integration: city overview** | `scripts/fp/overview/**`, `tools/tests/fp_overview_test.gd` | Tab on foot: an aerial camera over the city (through `FpPlayer.set_view_override`, so `player.camera` stays the one camera) with a marker per open construction site and a site list. N / B pick sites, F walks the hero to a vantage point by the site, G sets a waypoint, Tab or Esc return. "Planner view" leaves for orbit; Tab in orbit comes back to where the hero stood (`Main._fp_saved`). | | **Integration: driving** | `scripts/fp/drive/**`, `tools/tests/fp_drive_test.gd` | F by a car takes it over (its resident's trip ends; traffic hands over a `player`-role car of the same model that the AI no longer steps). An arcade bicycle model (`drive_model.gd`: throttle, brake, reverse, speed-scaled steering, grip by surface, handbrake slide) moves it through `TrafficFeature.drive_car`; the hero sits in the driver's seat. Chase camera (sweep-checked) or driver view on V, a speedometer and gear chip, crashes bounce off `CollisionWorld` boxes. AI cars and walkers yield to player cars as oriented boxes. F again gets out on the driver's side when slow (under 8.5 m/s) and there is room; a left car is removed once out of range and out of sight. | | **Integration: phone** | `scripts/fp/phone/**`, `tools/tests/fp_phone_test.gd` | B opens a phone with a Build app: road, homes, shops, industry, a building from the service list, and bulldoze, on the tile ahead of the hero (the camera's pitch sets the distance, 0.6 to 6 tiles). Each order goes through `CitySim.apply_action` for the mayor who owns that tile, so it is paid from that district's treasury and follows the mayors' rules and construction sites; unclaimed land is refused. `build_rules.gd` mirrors the sim's checks for the preview: a cyan or amber ghost over the tiles, the owner, the price and why not. Roads and zones take two clicks (start or corner, then end). Put away while driving or under a menu. | +| **Integration: AI activity** | `scripts/fp/activity/**`, `tools/tests/fp_activity_test.gd` | Shows the city's AI at work from the street. Every mayor turn, council ruling, Chaos Director pick, Grid Operator order and Gazette front page lands as a card in a feed at the top right (J cycles full, brief and hidden). A card names who decided and the model they run on, and says whether the answer came live, from a replay, from a fallback after a failed live call, or from the offline planner. It also shows what they did and why, any votes, and where. A decision with a place raises a light beacon in the agent's colour (tagged within 30 tiles) and pings the compass and minimap (`ai:`, no toast). G routes the GPS to the newest decision with a place. On foot it turns on `CityBrain.stagger_mayors`, so the five mayors take their turns spread over the day and the header line says who is deciding or who goes next. The feed hides under modals (not under the chat input), and the beacons hide in photo mode. | ## 6. Rules for every slice diff --git a/project.godot b/project.godot index 49c0a9cd..4c2e2eca 100644 --- a/project.godot +++ b/project.godot @@ -265,6 +265,11 @@ phone_toggle={ "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":66,"key_label":0,"unicode":98,"location":0,"echo":false,"script":null) ] } +ai_feed={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":74,"key_label":0,"unicode":106,"location":0,"echo":false,"script":null) +] +} [rendering] diff --git a/scripts/ai/brain_text.gd b/scripts/ai/brain_text.gd new file mode 100644 index 00000000..e8055fda --- /dev/null +++ b/scripts/ai/brain_text.gd @@ -0,0 +1,44 @@ +extends RefCounted +## Removes model brand names from CityBrain text: a Gazette page as it comes in, and state restored +## from saves written before the display names, whose text feeds the prompts and the HUDs. No nodes. + +const D := preload("res://scripts/sim/sim_defs.gd") + + +## De-brands a Gazette front page in place: headline, editorial, and each story's title and body. +static func debrand_paper(paper: Dictionary) -> void: + for key: String in ["headline", "editorial"]: + if paper.has(key): + paper[key] = D.debrand(str(paper[key])) + if typeof(paper.get("stories")) == TYPE_ARRAY: + for st: Variant in paper["stories"]: + if typeof(st) == TYPE_DICTIONARY: + for key: String in ["title", "body"]: + if (st as Dictionary).has(key): + (st as Dictionary)[key] = D.debrand(str((st as Dictionary)[key])) + + +## De-brands restored brain state in place: proposal authors, mayor posts, thoughts and log +## entries, the Gazette and the council ruling. +static func debrand_state( + proposals: Dictionary, mayor_state: Array[Dictionary], paper: Dictionary, council: Dictionary +) -> void: + for key: Variant in proposals.keys(): + if typeof(proposals[key]) == TYPE_DICTIONARY: + var pr: Dictionary = proposals[key] + pr["by"] = D.debrand(str(pr.get("by", ""))) + for ms: Dictionary in mayor_state: + ms["post"] = D.debrand(str(ms.get("post", ""))) + if typeof(ms.get("thoughts")) == TYPE_ARRAY: + var th: Array = ms["thoughts"] + for k in range(th.size()): + th[k] = D.debrand(str(th[k])) + if typeof(ms.get("log")) == TYPE_ARRAY: + for e: Variant in ms["log"]: + if typeof(e) == TYPE_DICTIONARY: + for key: String in ["thought", "post"]: + if (e as Dictionary).has(key): + (e as Dictionary)[key] = D.debrand(str((e as Dictionary)[key])) + debrand_paper(paper) + if council.has("statement"): + council["statement"] = D.debrand(str(council["statement"])) diff --git a/scripts/ai/brain_text.gd.uid b/scripts/ai/brain_text.gd.uid new file mode 100644 index 00000000..7f8b58a1 --- /dev/null +++ b/scripts/ai/brain_text.gd.uid @@ -0,0 +1 @@ +uid://cutbu5iyi4uir diff --git a/scripts/ai/city_brain.gd b/scripts/ai/city_brain.gd index 402ce093..268086b7 100644 --- a/scripts/ai/city_brain.gd +++ b/scripts/ai/city_brain.gd @@ -21,6 +21,8 @@ signal inspector_answered(report: Dictionary) signal busy_changed(pending: int) const D := preload("res://scripts/sim/sim_defs.gd") +const MayorTurns := preload("res://scripts/ai/mayor_turns.gd") +const BrainText := preload("res://scripts/ai/brain_text.gd") const MAX_WAIT_MS := 75000 var sim: CitySim @@ -43,6 +45,10 @@ var minds: ResidentMinds = null var _mayor_in_flight: Array[bool] = [false, false, false, false, false] var _support_in_flight: Dictionary = {} var _wait_started_ms: int = 0 +## Street mode sets this: the mayors take the day's turns one by one through the day (turns), so +## a player on foot sees decisions land every few minutes. Off (orbit, tests): all at dawn. +var stagger_mayors: bool = false +var turns: MayorTurns = MayorTurns.new() ## The day round's pieces still waiting for a frame, and the round's trace id and label. var _queue: Array[Callable] = [] var _queue_trace: String = "" @@ -68,6 +74,7 @@ func setup(p_sim: CitySim, p_client: GmiClient) -> void: chaos_last_day = -10 day_feed.clear() _queue.clear() + turns.clear() if sim.has_method("add_spread_source"): sim.add_spread_source(spread_pending, _finish_queue) if sim.day_started.is_connected(_on_day_started): @@ -100,6 +107,9 @@ func mayors_pending() -> int: func _day_gate() -> bool: + # A staggered mayor who was still deciding when the day ended takes the turn once free. + for mi: int in turns.take_all(_mayor_in_flight): + _run_turn(mi) var busy := mayors_pending() + (minds.pending() if minds != null else 0) if not wait_for_ai or busy == 0: _wait_started_ms = 0 @@ -122,8 +132,9 @@ func send_decree(text: String) -> void: ## orbit, tests) runs it inline. Every piece runs under the round's trace id. func _on_day_started(day: int) -> void: var pieces: Array[Callable] = [] - for mi in range(D.MAYORS.size()): - pieces.append(_mayor_piece.bind(mi, day)) + if not turns.start(day, stagger_mayors): + for mi in range(D.MAYORS.size()): + pieces.append(_mayor_piece.bind(mi, day)) for role: String in ["citizens", "news", "chaos", "operator", "council"]: pieces.append(_support_piece.bind(role, day)) var prev := [Trace.current(), Trace.current_label()] @@ -155,6 +166,18 @@ func _process(_delta: float) -> void: _run_piece(_queue.pop_front()) if _queue.is_empty(): busy_changed.emit(pending_calls()) + if not turns.due.is_empty() and MayorTurns.sim_idle(sim): + _run_turn(turns.take_due(sim.day_progress(), stagger_mayors, _mayor_in_flight)) + + +## A staggered mayor turn under its own trace id; -1 (none due) or a mayor still deciding: nothing. +func _run_turn(mi: int) -> void: + if mi < 0 or _mayor_in_flight[mi]: + return + var prev := [Trace.current(), Trace.current_label()] + Trace.begin("day %d turn %s" % [turns.day, str(D.MAYORS[mi]["id"])]) + _run_mayor(mi, turns.day) + Trace.set_current(prev[0], prev[1]) ## One deferred piece under the round's trace id, as part of the sim's job phase. @@ -208,6 +231,9 @@ func _support_due(role: String, day: int) -> bool: func _on_day_ended(_day: int) -> void: + # Staggered turns still waiting run now, on their own day and before its calls are flushed. + for mi: int in turns.take_all(_mayor_in_flight): + _run_turn(mi) client.flush_cache() @@ -337,15 +363,19 @@ func _run_mayor(mi: int, day: int) -> void: if ok and focus == D.NO_TILE and a.has("x"): focus = Vector2i(int(r.get("x", a.get("x", -1))), int(r.get("y", a.get("y", -1)))) var prop := str(data.get("propose_ordinance", "")).strip_edges().to_lower() + var proposed := "" if D.ORDINANCES.has(prop) and not sim.ordinances.has(prop) and not proposals.has(prop): proposals[prop] = {"by": m["display"], "day": day, "votes": {str(m["id"]): "yes"}} + proposed = prop day_feed.append("%s proposed the %s ordinance." % [m["display"], D.ORDINANCES[prop]["label"]]) + var cast := {} var votes: Variant = data.get("votes", {}) if typeof(votes) == TYPE_DICTIONARY: for key: Variant in (votes as Dictionary).keys(): var ks := str(key).to_lower() if proposals.has(ks): - (proposals[ks]["votes"] as Dictionary)[str(m["id"])] = str((votes as Dictionary)[key]).to_lower() + cast[ks] = str((votes as Dictionary)[key]).to_lower() + (proposals[ks]["votes"] as Dictionary)[str(m["id"])] = cast[ks] var ms: Dictionary = mayor_state[mi] var thought := D.debrand(str(data.get("thought", ""))).left(260) var post := D.debrand(str(data.get("public_post", ""))).left(200) @@ -358,7 +388,7 @@ func _run_mayor(mi: int, day: int) -> void: ms["source"] = res["source"] ms["latency_ms"] = res["latency_ms"] ms["day"] = day - var entry := {"day": day, "thought": thought, "post": post, "mood": ms["mood"], "actions": applied, "source": res["source"], "latency_ms": res["latency_ms"], "error": res.get("error", ""), "model": m["model"], "focus": focus} + var entry := {"day": day, "thought": thought, "post": post, "mood": ms["mood"], "actions": applied, "source": res["source"], "latency_ms": res["latency_ms"], "error": res.get("error", ""), "model": m["model"], "focus": focus, "proposal": proposed, "votes": cast} (ms["log"] as Array).append(entry) if (ms["log"] as Array).size() > 12: (ms["log"] as Array).pop_front() @@ -697,7 +727,7 @@ func _run_news(day: int) -> void: var user := "Day %d front page.\nRecent events and statements:\n%s\nCity trend (last days): %s\nCity now: %s" % [day, "\n".join(PackedStringArray(feed)) if not feed.is_empty() else "(quiet day)", JSON.stringify(hist), JSON.stringify(sim.city_summary())] var res: Dictionary = await client.chat_json("news", "gazette", str(agent["model"]), sys, user, day, 1000, 40.0, _heuristic_news.bind(feed)) var paper: Dictionary = res["data"].duplicate(true) - _debrand_paper(paper) + BrainText.debrand_paper(paper) paper["day"] = day paper["source"] = res["source"] last_paper = paper @@ -934,6 +964,7 @@ func to_dict() -> Dictionary: "decree_day": decree_day, "last_paper": last_paper, "last_voices": last_voices, "last_council": last_council, "last_chaos": last_chaos, "last_operator": last_operator, "chaos_last_day": chaos_last_day, "complaints": complaints.duplicate(true), "client": client.to_dict(), + "turns": turns.to_dict(), } @@ -958,42 +989,6 @@ func from_dict(d: Dictionary) -> void: complaints.append(c as Dictionary) while complaints.size() < D.MAYORS.size(): complaints.append({}) + turns.from_dict(d.get("turns", {})) client.from_dict(d.get("client", {})) - _debrand_restored() - - -## De-brands a Gazette front page in place: headline, editorial, and each story's title and body. -func _debrand_paper(paper: Dictionary) -> void: - for key: String in ["headline", "editorial"]: - if paper.has(key): - paper[key] = D.debrand(str(paper[key])) - if typeof(paper.get("stories")) == TYPE_ARRAY: - for st: Variant in paper["stories"]: - if typeof(st) == TYPE_DICTIONARY: - for key: String in ["title", "body"]: - if (st as Dictionary).has(key): - (st as Dictionary)[key] = D.debrand(str((st as Dictionary)[key])) - - -## Saves from before the display names carry brand names in text that feeds the prompts and the -## HUDs: proposal authors, mayor posts, thoughts and log entries, the Gazette and the council ruling. -func _debrand_restored() -> void: - for key: Variant in proposals.keys(): - if typeof(proposals[key]) == TYPE_DICTIONARY: - var pr: Dictionary = proposals[key] - pr["by"] = D.debrand(str(pr.get("by", ""))) - for ms: Dictionary in mayor_state: - ms["post"] = D.debrand(str(ms.get("post", ""))) - if typeof(ms.get("thoughts")) == TYPE_ARRAY: - var th: Array = ms["thoughts"] - for k in range(th.size()): - th[k] = D.debrand(str(th[k])) - if typeof(ms.get("log")) == TYPE_ARRAY: - for e: Variant in ms["log"]: - if typeof(e) == TYPE_DICTIONARY: - for key: String in ["thought", "post"]: - if (e as Dictionary).has(key): - (e as Dictionary)[key] = D.debrand(str((e as Dictionary)[key])) - _debrand_paper(last_paper) - if last_council.has("statement"): - last_council["statement"] = D.debrand(str(last_council["statement"])) + BrainText.debrand_state(proposals, mayor_state, last_paper, last_council) diff --git a/scripts/ai/mayor_turns.gd b/scripts/ai/mayor_turns.gd new file mode 100644 index 00000000..675e06c3 --- /dev/null +++ b/scripts/ai/mayor_turns.gd @@ -0,0 +1,105 @@ +extends RefCounted +## Staggered mayor turns (CityBrain.stagger_mayors, on in street mode). Instead of all five mayors +## deciding at dawn, they take the day's turns one by one, spread over the first SPAN of the day in +## an order that rotates daily, so a player on foot sees decisions land every few minutes. A turn +## still waiting when the day ends runs then (CityBrain._on_day_ended). No nodes. + +const D := preload("res://scripts/sim/sim_defs.gd") +## Share of the day the turns are spread over. +const SPAN := 0.9 + +## Mayors still to take the day's turn, in turn order. +var due: Array[int] = [] +## The day the waiting turns belong to. +var day: int = -1 + + +## Starts p_day: queues every mayor's turn when staggered. Returns staggered; false leaves the +## queue empty for the brain to run the mayors at dawn. +func start(p_day: int, staggered: bool) -> bool: + due.clear() + day = p_day + if staggered: + due.assign(order(p_day)) + return staggered + + +## The mayor whose turn has come at progress (0..1 through the day), taken off the queue, or -1. +## Not staggered (street mode left mid-day), the next waiting mayor goes at once. A mayor still +## deciding (busy[mi], e.g. a live call from the day before) keeps the turn and holds the queue. +func take_due(progress: float, staggered: bool, busy: Array = []) -> int: + if due.is_empty() or (staggered and progress < at(D.MAYORS.size() - due.size())): + return -1 + if _busy(busy, due[0]): + return -1 + return due.pop_front() + + +## Every waiting turn whose mayor is free, taken off the queue (the day is ending). A mayor still +## deciding (busy[mi]) keeps the turn. +func take_all(busy: Array = []) -> Array[int]: + var out: Array[int] = [] + for mi: int in due: + if not _busy(busy, mi): + out.append(mi) + for mi: int in out: + due.erase(mi) + return out + + +## The next turn: {"mi": mayor index, "at": the day fraction it starts}, or {} when nobody waits. +func next_turn() -> Dictionary: + if due.is_empty(): + return {} + return {"mi": due[0], "at": at(D.MAYORS.size() - due.size())} + + +func clear() -> void: + due.clear() + day = -1 + + +func to_dict() -> Dictionary: + return {"due": due.duplicate(), "day": day} + + +## Restores a saved queue; unknown or repeated mayor indices are dropped. +func from_dict(d: Variant) -> void: + clear() + if typeof(d) != TYPE_DICTIONARY: + return + var saved: Variant = (d as Dictionary).get("due", []) + if typeof(saved) == TYPE_ARRAY: + for v: Variant in saved: + var mi := int(v) if typeof(v) in [TYPE_INT, TYPE_FLOAT] else -1 + if mi >= 0 and mi < D.MAYORS.size() and not due.has(mi): + due.append(mi) + var saved_day: Variant = (d as Dictionary).get("day", -1) + day = int(saved_day) if typeof(saved_day) in [TYPE_INT, TYPE_FLOAT] else -1 + + +## The order of p_day's turns, rotated daily so nobody always goes first. +static func order(p_day: int) -> Array[int]: + var out: Array[int] = [] + for k in range(D.MAYORS.size()): + out.append(posmod(p_day + k, D.MAYORS.size())) + return out + + +## The day fraction at which the k-th turn starts. +static func at(k: int) -> float: + return SPAN * (float(k) + 0.5) / float(D.MAYORS.size()) + + +## True while sim has no sliced job or spread work in flight (always for a synchronous sim). A +## staggered turn starts only then: its actions come from outside any job, and a mutator from +## outside finishes the job in flight, and the spread work it waits for, in that one frame. +static func sim_idle(sim: Object) -> bool: + for pending: String in ["job_pending", "spread_pending"]: + if sim.has_method(pending) and bool(sim.call(pending)): + return false + return true + + +static func _busy(busy: Array, mi: int) -> bool: + return mi < busy.size() and bool(busy[mi]) diff --git a/scripts/ai/mayor_turns.gd.uid b/scripts/ai/mayor_turns.gd.uid new file mode 100644 index 00000000..a35b0951 --- /dev/null +++ b/scripts/ai/mayor_turns.gd.uid @@ -0,0 +1 @@ +uid://bmbk58cq3yf40 diff --git a/scripts/fp/activity/activity_beacons.gd b/scripts/fp/activity/activity_beacons.gd new file mode 100644 index 00000000..ea367654 --- /dev/null +++ b/scripts/fp/activity/activity_beacons.gd @@ -0,0 +1,160 @@ +extends Node3D +## World beacons for the AI activity layer: one per agent key ("mayor:1", "council", "chaos"), a +## tall light column in the agent's colour over the tile its latest decision touched, with a ring +## on the ground that pulses while the decision is new and dims once it is old. Visual only: no +## collision, hidden in photo mode by the feature. + +const WS := preload("res://scripts/fp/world_scale.gd") + +const BEAM_H := 110.0 * WS.M +const BEAM_R := 0.9 * WS.M +const RING_R := 7.0 * WS.M +const RING_W := 0.6 * WS.M +## Seconds a beacon pulses as new, and after which it settles to its dim strength. +const FRESH := 30.0 +const STALE := 480.0 +const BEAM_SHADER := """ +shader_type spatial; +render_mode unshaded, blend_add, cull_disabled, depth_draw_never, shadows_disabled; +uniform vec4 tint : source_color = vec4(0.0, 0.9, 1.0, 1.0); +uniform float strength = 0.6; +uniform float height = 5.5; +varying float h; +void vertex() { + h = clamp(VERTEX.y / height + 0.5, 0.0, 1.0); +} +void fragment() { + float core = abs(dot(NORMAL, VIEW)); + float fade = (1.0 - h) * (1.0 - h); + ALBEDO = tint.rgb * strength * fade * (0.35 + 0.65 * core); + ALPHA = clamp(strength * fade, 0.0, 1.0); +} +""" + +static var _shader: Shader + +## key -> {"node": Node3D, "beam": ShaderMaterial, "ring": StandardMaterial3D, +## "ring_node": MeshInstance3D, "age": float, "color": Color, "pos": Vector3} +var _beacons: Dictionary = {} + + +## Puts the beacon for key at pos (feet on the ground) in col; fresh restarts its pulse. +func place(key: String, pos: Vector3, col: Color, fresh: bool = true) -> void: + var b: Dictionary = _beacons.get(key, {}) + if b.is_empty(): + b = _make(col) + _beacons[key] = b + add_child(b["node"]) + (b["node"] as Node3D).position = pos + b["pos"] = pos + b["color"] = col + (b["beam"] as ShaderMaterial).set_shader_parameter("tint", col) + b["age"] = 0.0 if fresh else STALE + _style(b) + + +func remove(key: String) -> void: + if _beacons.has(key): + (_beacons[key]["node"] as Node).queue_free() + _beacons.erase(key) + + +func clear() -> void: + for key: String in _beacons.keys(): + remove(key) + + +func has_beacon(key: String) -> bool: + return _beacons.has(key) + + +func position_of(key: String) -> Vector3: + return _beacons[key]["pos"] if _beacons.has(key) else Vector3.ZERO + + +func keys() -> Array: + return _beacons.keys() + + +## 0..1: how new the beacon is (1 just placed, 0 once FRESH has passed). +func freshness(key: String) -> float: + if not _beacons.has(key): + return 0.0 + return 1.0 - clampf(float(_beacons[key]["age"]) / FRESH, 0.0, 1.0) + + +func _process(delta: float) -> void: + for key: String in _beacons: + var b: Dictionary = _beacons[key] + if float(b["age"]) >= STALE: + continue + b["age"] = float(b["age"]) + delta + _style(b) + + +func _style(b: Dictionary) -> void: + var age := float(b["age"]) + var fresh := 1.0 - clampf(age / FRESH, 0.0, 1.0) + var settle := 1.0 - smoothstep(FRESH, STALE, age) + var pulse := 0.5 + 0.5 * sin(age * 5.0) + var strength := lerpf(0.32, 0.6, settle) + 0.35 * fresh * pulse + (b["beam"] as ShaderMaterial).set_shader_parameter("strength", strength) + var col: Color = b["color"] + var ring: StandardMaterial3D = b["ring"] + ring.albedo_color = Color(col.r, col.g, col.b, clampf(0.35 + 0.5 * fresh * pulse, 0.0, 1.0)) + var s := 1.0 + 0.25 * fresh * pulse + (b["ring_node"] as Node3D).scale = Vector3(s, 1.0, s) + + +func _make(col: Color) -> Dictionary: + var root := Node3D.new() + root.name = "Beacon" + if _shader == null: + _shader = Shader.new() + _shader.code = BEAM_SHADER + var mat := ShaderMaterial.new() + mat.shader = _shader + mat.set_shader_parameter("tint", col) + mat.set_shader_parameter("height", BEAM_H) + var cyl := CylinderMesh.new() + cyl.top_radius = BEAM_R * 0.6 + cyl.bottom_radius = BEAM_R + cyl.height = BEAM_H + cyl.radial_segments = 12 + cyl.rings = 1 + cyl.cap_top = false + cyl.cap_bottom = false + var beam := MeshInstance3D.new() + beam.name = "Beam" + beam.mesh = cyl + beam.material_override = mat + beam.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + beam.position.y = BEAM_H * 0.5 + root.add_child(beam) + var torus := TorusMesh.new() + torus.inner_radius = RING_R - RING_W + torus.outer_radius = RING_R + torus.rings = 48 + torus.ring_segments = 6 + var rm := StandardMaterial3D.new() + rm.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + rm.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + rm.blend_mode = BaseMaterial3D.BLEND_MODE_ADD + rm.albedo_color = col + var ring := MeshInstance3D.new() + ring.name = "Ring" + ring.mesh = torus + ring.material_override = rm + ring.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + ring.position.y = 0.15 * WS.M + ring.scale = Vector3(1.0, 0.15, 1.0) + root.add_child(ring) + return { + "node": root, + "beam": mat, + "ring": rm, + "ring_node": ring, + "age": 0.0, + "color": col, + "pos": Vector3.ZERO, + } diff --git a/scripts/fp/activity/activity_beacons.gd.uid b/scripts/fp/activity/activity_beacons.gd.uid new file mode 100644 index 00000000..a84fc1e9 --- /dev/null +++ b/scripts/fp/activity/activity_beacons.gd.uid @@ -0,0 +1 @@ +uid://cgipffi2whotl diff --git a/scripts/fp/activity/activity_feature.gd b/scripts/fp/activity/activity_feature.gd new file mode 100644 index 00000000..02c75059 --- /dev/null +++ b/scripts/fp/activity/activity_feature.gd @@ -0,0 +1,507 @@ +extends FpFeature +## AI activity (integration feature "activity"): the city's AI decisions, visible from the street. +## Every mayor's turn, council ruling, Chaos Director pick, grid order and Gazette front page lands +## as a card in the feed (top right) that names who decided and the model they run on, whether +## the answer came live, from a replay, a fallback or the offline planner, what they did and why, +## and where. A decision with a place also raises a light beacon there (tagged when the hero is +## near) and pings the compass and minimap in the agent's colour. In street mode the mayors take +## their turns spread over the day (CityBrain.stagger_mayors), so decisions keep landing while you +## walk. J cycles the feed (full, brief, hidden); G routes the GPS to the newest decision with a +## place. + +const D := preload("res://scripts/sim/sim_defs.gd") +const WS := preload("res://scripts/fp/world_scale.gd") +const S := preload("res://scripts/fp/ui/hud_style.gd") +const W := preload("res://scripts/fp/activity/activity_words.gd") +const FEED := preload("res://scripts/fp/activity/activity_feed.gd") +const BEACONS := preload("res://scripts/fp/activity/activity_beacons.gd") +const TAGS := preload("res://scripts/fp/activity/activity_tags.gd") +## Where the council sits when no City Hall tile is found (CitySim.new_city builds it here). +const CITY_HALL := Vector2i(15, 14) +## Decisions kept for the save; the feed shows the newest FEED.MAX_CARDS. +const KEEP := 12 +const REFRESH := 0.25 +## Within this many tiles the place line reads "here" instead of a distance. +const HERE := 0.75 +## Height of the chat ticker strip at the bottom of chat_slot (the last lines while it is closed). +const TICKER_H := 240.0 +const SAVED_TEXT := [ + "key", "agent", "who", "model", "source", "headline", "short", "detail", "extra" +] + +var feed: FEED +var beacons: BEACONS +var tags: TAGS +## Oldest first: {"key" ("mayor:" or the support role), "agent" ("" or the role), "who", +## "model", "source" (live|cache|heuristic), "failed" (a live call was tried and failed), +## "headline", "short" (its first phrase), "detail" (why), "extra" (votes or a refusal), +## "color": Color, "tile": Vector2i (D.NO_TILE without a place), "placed": bool, "day", +## "clock" (feature seconds when it landed; -1 once restored from a save)}. +var items: Array[Dictionary] = [] +var _clock: float = 0.0 +## Mayors waiting on their model right now, in the order they started. +var _thinking: Array[int] = [] +var _links: Array[Array] = [] +var _stagger_was: bool = false +var _t: float = 0.0 + + +func feature_id() -> String: + return "activity" + + +func setup(p_ctx: FpContext) -> void: + super.setup(p_ctx) + if ctx.hud != null: + var center := ctx.hud.slot("center") + tags = TAGS.new() + tags.setup() + center.add_child(tags) + center.move_child(tags, 0) + feed = FEED.new() + feed.setup() + center.add_child(feed) + beacons = BEACONS.new() + beacons.name = "Beacons" + add_child(beacons) + if ctx.brain != null: + _stagger_was = ctx.brain.stagger_mayors + ctx.brain.stagger_mayors = true + _link(ctx.brain.mayor_thinking, _on_mayor_thinking) + _link(ctx.brain.mayor_decided, _on_mayor_decided) + _link(ctx.brain.council_ruled, _on_council_ruled) + _link(ctx.brain.chaos_decided, _on_chaos_decided) + _link(ctx.brain.operator_decided, _on_operator_decided) + _link(ctx.brain.news_published, _on_news_published) + if ctx.bus != null: + _link(ctx.bus.modal_changed, _sync_visibility) + _refresh() + + +func teardown() -> void: + for l: Array in _links: + var sig: Signal = l[0] + var src := sig.get_object() + if src != null and is_instance_valid(src) and sig.is_connected(l[1]): + sig.disconnect(l[1]) + _links.clear() + if ctx != null and ctx.brain != null and is_instance_valid(ctx.brain): + ctx.brain.stagger_mayors = _stagger_was + for n: Node in [feed, tags, beacons]: + if n != null and is_instance_valid(n): + n.queue_free() + feed = null + tags = null + beacons = null + + +func to_dict() -> Dictionary: + var mode := feed.mode if feed != null else "full" + if items.is_empty() and mode == "full": + return {} + var out: Array = [] + for it: Dictionary in items: + var e := it.duplicate() + var t: Vector2i = it["tile"] + e["tile"] = [t.x, t.y] + e["color"] = (it["color"] as Color).to_html(false) + e.erase("clock") + out.append(e) + return {"items": out, "mode": mode} + + +func from_dict(d: Dictionary) -> void: + items.clear() + _thinking.clear() + if feed != null: + feed.clear() + feed.set_mode(str(d.get("mode", "full"))) + if beacons != null: + beacons.clear() + var saved: Variant = d.get("items", []) + if typeof(saved) == TYPE_ARRAY: + for v: Variant in (saved as Array).slice(-KEEP): + if typeof(v) == TYPE_DICTIONARY: + _add(_restored(v as Dictionary), false) + _refresh() + + +func self_test() -> Dictionary: + if feed == null or beacons == null: + return {"ok": false, "detail": "activity feed or beacons missing"} + var staggered := ctx.brain != null and ctx.brain.stagger_mayors + var detail := "%d decisions, staggered turns %s" % [items.size(), "on" if staggered else "off"] + return {"ok": staggered or ctx.brain == null, "detail": detail} + + +# ------------------------------------------------------------------ player actions + + +## J: full -> brief -> hidden -> full. Returns the new mode ("" without a HUD). +func cycle_feed() -> String: + if feed == null: + return "" + var m := feed.cycle_mode() + _refresh() + Analytics.track("ai_feed_mode", {"mode": m}) + return m + + +## G: routes the GPS to the newest decision with a place. False when there is none or no map. +func route_to_newest() -> bool: + var it := newest_placed() + if it.is_empty() or ctx.hud == null: + _toast("No AI decision has a place on the map yet.", "info") + return false + var t: Vector2i = it["tile"] + ctx.hud.set_waypoint(_goal(t)) + if ctx.hud.waypoint() == null: + return false + _toast("Route set: %s, %s" % [str(it["short"]), _place_name(t)], "info") + Analytics.track("ai_route", {"agent": str(it["agent"])}) + return true + + +## The newest decision with a place, or {}. +func newest_placed() -> Dictionary: + for k in range(items.size() - 1, -1, -1): + if bool(items[k]["placed"]): + return items[k] + return {} + + +## The open card's place line: "Maple Street · 240 m NE", or the mayor's district ("Citywide" +## for the support agents) for a decision without a place. +func where_of(it: Dictionary) -> String: + if not bool(it.get("placed", false)): + var agent := str(it.get("agent", "")) + if agent.is_valid_int() and int(agent) >= 0 and int(agent) < D.MAYORS.size(): + return str(D.MAYORS[int(agent)]["district"]) + return "Citywide" + var t: Vector2i = it["tile"] + var me := ctx.player_position() + var at := Vector3(t.x, 0.0, t.y) + var d := Vector2(at.x - me.x, at.z - me.z).length() + if d < HERE: + return "%s · here" % _place_name(t) + return "%s · %s %s" % [_place_name(t), S.distance(d), W.direction(me, at)] + + +func _unhandled_input(event: InputEvent) -> void: + if ctx == null or event.is_echo() or ctx.is_modal(): + return + if event.is_action_pressed("ai_feed"): + cycle_feed() + elif event.is_action_pressed("site_waypoint") and not (event is InputEventJoypadButton): + # The pad's Y also opens the map on foot; only the G key routes. + route_to_newest() + else: + return + get_viewport().set_input_as_handled() + + +func _process(delta: float) -> void: + _clock += delta + _t -= delta + if _t <= 0.0: + _refresh() + _sync_visibility() + + +## The feed hides under menus and cards but not the chat input, the tags under both, and the +## beacons in photo mode. Also runs on bus.modal_changed: the pause menu stops _process. +func _sync_visibility(_modal := false) -> void: + var covered := _covered() + if feed != null: + feed.visible = not covered + if beacons != null: + beacons.visible = ctx.player == null or not ctx.player.photo_mode + _update_tags(covered) + + +# ------------------------------------------------------------------ brain signals + + +func _on_mayor_thinking(mi: int) -> void: + if not _thinking.has(mi): + _thinking.append(mi) + _refresh_line() + + +func _on_mayor_decided(mi: int, entry: Dictionary) -> void: + _thinking.erase(mi) + if mi < 0 or mi >= D.MAYORS.size(): + return + var col := Color(str(D.MAYORS[mi]["color"])) + var it := _item("mayor:%d" % mi, str(mi), D.mayor_display(mi), W.mayor_model(mi), col, entry) + var applied: Array = ( + entry.get("actions", []) if typeof(entry.get("actions")) == TYPE_ARRAY else [] + ) + it["failed"] = not str(entry.get("error", "")).is_empty() + it["headline"] = W.summarize(applied) + var why := str(entry.get("thought", "")) + it["detail"] = W.detail(why if not why.strip_edges().is_empty() else str(entry.get("post", ""))) + var extra := W.politics(entry) + var refusal := W.first_refusal(applied) + if extra.is_empty() and not refusal.is_empty(): + extra = W.detail("Refused: " + refusal) + it["extra"] = extra + _place(it, W.tile_of(entry.get("focus", D.NO_TILE))) + _add(it) + + +func _on_council_ruled(r: Dictionary) -> void: + var it := _agent_item("council", r) + it["headline"] = W.council_words(r) + it["detail"] = W.detail(str(r.get("statement", ""))) + _place(it, _city_hall()) + _add(it) + + +func _on_chaos_decided(d: Dictionary) -> void: + var it := _agent_item("chaos", d) + it["headline"] = W.chaos_words(d) + var why := str(d.get("reason", "")) + it["detail"] = W.detail( + why if not why.strip_edges().is_empty() else str(d.get("narration", "")) + ) + if bool(d.get("trigger", false)) and d.has("x"): + _place(it, Vector2i(int(d["x"]), int(d.get("y", 0)))) + # The disaster pings the map itself (event:); a second marker would only clutter it. + _add(it, true, false) + + +func _on_operator_decided(d: Dictionary) -> void: + var it := _agent_item("operator", d) + it["headline"] = W.operator_words(d) + var why := str(d.get("note", "")) + it["detail"] = W.detail(why if not why.strip_edges().is_empty() else str(d.get("advice", ""))) + _add(it) + + +func _on_news_published(p: Dictionary) -> void: + var it := _agent_item("news", p) + it["headline"] = "Front page: " + W.detail(str(p.get("headline", ""))) + it["detail"] = W.detail(str(p.get("editorial", ""))) + _add(it) + + +# ------------------------------------------------------------------ items + + +func _item( + key: String, agent: String, who: String, model: String, col: Color, d: Dictionary +) -> Dictionary: + var day := int(d.get("day", ctx.sim.day if ctx.sim != null else 0)) + return { + "key": key, + "agent": agent, + "who": who, + "model": model, + "color": col, + "source": str(d.get("source", "")), + "failed": false, + "headline": "", + "short": "", + "detail": "", + "extra": "", + "tile": D.NO_TILE, + "placed": false, + "day": day, + "clock": _clock, + } + + +func _agent_item(role: String, d: Dictionary) -> Dictionary: + var a: Dictionary = D.SUPPORT_AGENTS[role] + var col := Color(str(a["color"])) + return _item(role, role, str(a["name"]), W.model_label(str(a["model"])), col, d) + + +func _place(it: Dictionary, t: Vector2i) -> void: + var ok := t != D.NO_TILE and D.in_bounds(t.x, t.y) + it["tile"] = t if ok else D.NO_TILE + it["placed"] = ok + + +## Adds a decision: the feed card, the beacon over its place and (fresh, ping) a map ping. +func _add(it: Dictionary, fresh: bool = true, ping: bool = true) -> void: + it["short"] = str(it["headline"]).get_slice(" · ", 0).get_slice(" (", 0) + items.append(it) + while items.size() > KEEP: + items.pop_front() + if feed != null: + feed.add_item(it, fresh) + if bool(it["placed"]): + var pos := _ground(it["tile"]) + if beacons != null: + beacons.place(str(it["key"]), pos, it["color"], fresh) + if fresh and ping and ctx.bus != null: + ctx.bus.ping.emit(Vector3(pos.x, 0.0, pos.z), "ai:" + str(it["agent"])) + _refresh() + + +## A saved item back, its text de-branded again (saves are files anyone can edit). +func _restored(e: Dictionary) -> Dictionary: + var it := {} + for k: String in SAVED_TEXT: + it[k] = str(e.get(k, "")) + for k: String in ["who", "headline", "short", "detail", "extra"]: + it[k] = D.debrand(str(it[k])) + it["failed"] = bool(e.get("failed", false)) + it["day"] = int(e.get("day", 0)) + it["color"] = Color.from_string(str(e.get("color", "")), S.ACCENT) + it["clock"] = -1.0 + _place(it, W.tile_of(e.get("tile", null))) + return it + + +# ------------------------------------------------------------------ view + + +func _refresh() -> void: + _t = REFRESH + if feed == null: + return + feed.set_state(_live_state()) + _refresh_line() + feed.refresh(where_of, _clock) + + +## "live", "down" (live calls are failing, fallbacks answer) or "offline". +func _live_state() -> String: + var c := ctx.client + if c == null or not c.live_enabled: + return "offline" + if c.breaker_state() != "closed" or c.live_failure_ratio() > 0.5: + return "down" + return "live" + + +func _refresh_line() -> void: + if feed == null: + return + if _thinking.is_empty(): + feed.set_line(_next_text(), false) + return + var first := D.mayor_display(_thinking[0]) + var n := _thinking.size() + var t := ( + "%s is deciding…" % first if n == 1 else "%s and %d more are deciding…" % [first, n - 1] + ) + feed.set_line(t, true) + + +## "Next up: Rosa Quintero in 3 min" for the next staggered mayor turn today, or "". +func _next_text() -> String: + if ctx.brain == null or ctx.sim == null or not ctx.brain.stagger_mayors: + return "" + var nt := ctx.brain.turns.next_turn() + if nt.is_empty(): + return "" + var who := D.mayor_display(int(nt["mi"])) + if ctx.sim.speed <= 0: + return "Next up: %s" % who + var left := (float(nt["at"]) - ctx.sim.day_progress()) * ctx.sim.day_seconds + left /= float(ctx.sim.speed) + if left < 60.0: + return "Next up: %s, any moment" % who + return "Next up: %s in %d min" % [who, roundi(left / 60.0)] + + +## True while a modal covers the street (pause, map, overview, a card, a talk); the chat input +## sits on the other side of the screen and leaves the feed up. +func _covered() -> bool: + if not ctx.is_modal(): + return false + return not (ctx.modal_count() == 1 and ctx.has_modal(&"chat")) + + +## Tags over the beacons near the hero: each beacon shows its newest decision with a place. The +## open chat drawer would sit under them, so they wait while the player types. +func _update_tags(covered: bool) -> void: + if tags == null: + return + var typing := ctx.has_modal(&"chat") + tags.visible = not covered and not typing and feed != null and feed.mode != "hidden" + if not tags.visible or beacons == null or ctx.player == null: + return + var entries: Array = [] + var seen := {} + for k in range(items.size() - 1, -1, -1): + var it: Dictionary = items[k] + var key := str(it["key"]) + if seen.has(key) or not bool(it["placed"]) or not beacons.has_beacon(key): + continue + seen[key] = true + var e := {"key": key, "pos": beacons.position_of(key), "who": it["who"]} + e["what"] = it["short"] + e["color"] = it["color"] + entries.append(e) + tags.show_tags(entries, ctx.player.camera, ctx.player.position, _keep_clear()) + + +## Screen areas the tags stay out of, in HUD coordinates: the band under the top bar and compass +## (down to where the feed starts), the feed, the minimap and the chat ticker above it. +func _keep_clear() -> Array[Rect2]: + var out: Array[Rect2] = [] + if feed != null and feed.visible: + out.append(feed.column_rect()) + if ctx.hud == null: + return out + var screen := ctx.hud.slot("center").get_global_rect() + out.append(Rect2(screen.position, Vector2(screen.size.x, FEED.TOP))) + out.append(ctx.hud.slot("minimap_slot").get_global_rect()) + var chat := ctx.hud.slot("chat_slot").get_global_rect() + out.append(Rect2(chat.position.x, chat.end.y - TICKER_H, chat.size.x, TICKER_H)) + return out + + +# ------------------------------------------------------------------ places + + +## City Hall's tile, where the council sits. +func _city_hall() -> Vector2i: + if ctx.sim != null: + var i := ctx.sim.kind.find(D.HALL) + if i >= 0: + return Vector2i(D.tx(i), D.ty(i)) + return CITY_HALL + + +## A beacon's foot on tile t: the walkable surface there, street level over water. +func _ground(t: Vector2i) -> Vector3: + var p := Vector3(t.x, WS.GROUND_Y, t.y) + if ctx.collision != null: + var g := ctx.collision.ground_height(p) + if not is_nan(g): + p.y = g + return p + + +## Where the GPS should lead for tile t: its frontage road (a lot's centre is inside the +## building), else the tile itself. +func _goal(t: Vector2i) -> Vector3: + if ctx.directory != null and D.in_bounds(t.x, t.y): + var r := ctx.directory.frontage(D.idx(t.x, t.y)) + if r >= 0: + return Vector3(D.tx(r), 0.0, D.ty(r)) + return Vector3(t.x, 0.0, t.y) + + +func _place_name(t: Vector2i) -> String: + if ctx.directory == null or not D.in_bounds(t.x, t.y): + return "the frontier" + var s := D.debrand(ctx.directory.place_label(D.idx(t.x, t.y))) + return s if not s.is_empty() else "the frontier" + + +func _toast(t: String, kind: String) -> void: + if ctx.bus != null: + ctx.bus.toast.emit(t, kind) + + +func _link(sig: Signal, c: Callable) -> void: + if not sig.is_connected(c): + sig.connect(c) + _links.append([sig, c]) diff --git a/scripts/fp/activity/activity_feature.gd.uid b/scripts/fp/activity/activity_feature.gd.uid new file mode 100644 index 00000000..21c4156a --- /dev/null +++ b/scripts/fp/activity/activity_feature.gd.uid @@ -0,0 +1 @@ +uid://baya62006g18f diff --git a/scripts/fp/activity/activity_feed.gd b/scripts/fp/activity/activity_feed.gd new file mode 100644 index 00000000..0cad8799 --- /dev/null +++ b/scripts/fp/activity/activity_feed.gd @@ -0,0 +1,349 @@ +extends Control +## The AI activity feed (feature "activity"), top right under the compass: a header with the key +## and whether the city's agents answer live, a line for who is deciding now (or who goes next), +## then the newest decisions as cards. The newest card is open: who decided (in their colour) with +## the model badge, what they did, why (their private thought or ruling), any votes, and a footer +## with where the answer came from (live, a replay, a fallback or the offline planner) and where it +## happened. Older cards fold to who and what. J cycles full (MAX_CARDS), brief (the newest card) +## and hidden (the header only). + +const S := preload("res://scripts/fp/ui/hud_style.gd") +const W := preload("res://scripts/fp/activity/activity_words.gd") + +const WIDTH := 360.0 +## Text width inside a card: WIDTH less the 12 px side margins and the 3 px stripe. +const INNER := WIDTH - 27.0 +const SAFE := 24.0 +## Under the compass strip (it ends at y 106 in the 1600x900 base). +const TOP := 118.0 +const MAX_CARDS := 4 +const FADE := 0.25 +const FLASH := 1.6 +## Room kept at the end of a card's top row for its age ("12 min"). +const AGO_W := 44.0 +const MODES := ["full", "brief", "hidden"] +const OPEN_BG := Color(0.03, 0.04, 0.07, 0.74) +const FOLDED_BG := Color(0.03, 0.04, 0.07, 0.52) +const TAG_COLORS := {"LIVE": S.GOOD, "REPLAY": S.ACCENT, "FALLBACK": S.RED, "OFFLINE": S.AMBER} +const STATES := { + "live": ["Live", S.GOOD], "down": ["Reconnecting", S.RED], "offline": ["Offline", S.AMBER] +} + +var mode: String = "full" +var _col: VBoxContainer +var _title: Label +var _status_slot: HBoxContainer +var _state: String = "" +var _line_box: PanelContainer +var _line: Label +var _line_pulse: bool = false +var _list: VBoxContainer +## Newest first: {"item": Dictionary, "panel": PanelContainer, "box": StyleBoxFlat, +## "headline": Label, "ago": Label, "where": Label, "rich": Array[Control], "age": float} +var _cards: Array[Dictionary] = [] +var _pulse: float = 0.0 + + +func setup() -> void: + name = "ActivityFeed" + mouse_filter = Control.MOUSE_FILTER_IGNORE + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + _col = VBoxContainer.new() + _col.name = "Column" + _col.mouse_filter = Control.MOUSE_FILTER_IGNORE + _col.add_theme_constant_override("separation", 6) + _col.anchor_left = 1.0 + _col.anchor_right = 1.0 + _col.offset_left = -SAFE - WIDTH + _col.offset_right = -SAFE + _col.offset_top = TOP + add_child(_col) + _col.add_child(_header()) + _line_box = PanelContainer.new() + _line_box.name = "LineBox" + _line_box.mouse_filter = Control.MOUSE_FILTER_IGNORE + var lb := S.box(Color(0.03, 0.04, 0.07, 0.5), S.RADIUS, 10, 4) + _line_box.add_theme_stylebox_override("panel", lb) + _line = S.plain("", 13, S.TEXT_DIM, S.font()) + _line.name = "Line" + _line_box.add_child(_line) + _line_box.visible = false + _col.add_child(_line_box) + _list = VBoxContainer.new() + _list.name = "Cards" + _list.mouse_filter = Control.MOUSE_FILTER_IGNORE + _list.add_theme_constant_override("separation", 4) + _col.add_child(_list) + + +func _header() -> HBoxContainer: + var h := _row(8) + h.name = "Header" + _title = S.caps_label("AI activity", 12, S.ACCENT, 2) + _title.name = "Title" + h.add_child(_title) + var key := S.key_cap("J", 11) + key.size_flags_vertical = Control.SIZE_SHRINK_CENTER + h.add_child(key) + h.add_child(_fill()) + _status_slot = _row(0) + _status_slot.name = "State" + h.add_child(_status_slot) + return h + + +## Whether the city's agents answer from live model calls: "live", "down" (live calls are +## failing, so fallbacks answer) or "offline". +func set_state(state: String) -> void: + if state == _state or not STATES.has(state): + return + _state = state + for c in _status_slot.get_children(): + _status_slot.remove_child(c) + c.queue_free() + var s: Array = STATES[state] + _status_slot.add_child(S.badge(str(s[0]), s[1], 10)) + + +func state() -> String: + return _state + + +## The line under the header: who is deciding now (pulse) or who goes next; "" hides it. +func set_line(t: String, pulse: bool) -> void: + _line.text = t + _line_pulse = pulse + if not pulse: + _line.modulate.a = 1.0 + _line_box.visible = not t.is_empty() and mode != "hidden" + + +func line_text() -> String: + return _line.text if _line_box.visible else "" + + +## Adds a card for item (fields in ActivityFeature.items) on top and folds the one below. A fresh +## card fades in and flashes in its colour; a restored one (from a save) appears as it is. +func add_item(item: Dictionary, fresh: bool = true) -> void: + var c := _card(item) + _list.add_child(c["panel"]) + _list.move_child(c["panel"], 0) + c["age"] = 0.0 if fresh else FADE + FLASH + if fresh: + (c["panel"] as Control).modulate.a = 0.0 + _cards.push_front(c) + while _cards.size() > MAX_CARDS: + var old: Dictionary = _cards.pop_back() + _list.remove_child(old["panel"]) + (old["panel"] as Node).queue_free() + _apply_mode() + + +## Drops every card (load, New City). +func clear() -> void: + for c: Dictionary in _cards: + _list.remove_child(c["panel"]) + (c["panel"] as Node).queue_free() + _cards.clear() + + +## Refreshes every card's age and the open card's place line: where_of(item) -> String; now is +## the feature clock in seconds. +func refresh(where_of: Callable, now: float) -> void: + for k in range(_cards.size()): + var c: Dictionary = _cards[k] + var item: Dictionary = c["item"] + var clock := float(item.get("clock", -1.0)) + var a := W.ago(now - clock if clock >= 0.0 else -1.0, int(item.get("day", 0))) + var al: Label = c["ago"] + if al.text != a: + al.text = a + if k == 0: + var wl: Label = c["where"] + var wt := str(where_of.call(item)) + if wl.text != wt: + wl.text = wt + + +## J: full -> brief -> hidden -> full. +func cycle_mode() -> String: + set_mode(MODES[(MODES.find(mode) + 1) % MODES.size()]) + return mode + + +func set_mode(m: String) -> void: + mode = m if MODES.has(m) else "full" + _apply_mode() + + +## Headlines of the cards on screen, newest first. +func headlines() -> PackedStringArray: + var out := PackedStringArray() + for c: Dictionary in _cards: + if (c["panel"] as Control).visible: + out.append(str(c["item"].get("headline", ""))) + return out + + +## The newest (open) card's panel, or null. +func open_card() -> Control: + return _cards[0]["panel"] if not _cards.is_empty() else null + + +## How many cards the feed holds (shown or not). +func card_count() -> int: + return _cards.size() + + +## The header, line and cards as they sit on screen (HUD coordinates). +func column_rect() -> Rect2: + return _col.get_global_rect() + + +func _apply_mode() -> void: + var shown := MAX_CARDS if mode == "full" else (1 if mode == "brief" else 0) + for k in range(_cards.size()): + var c: Dictionary = _cards[k] + (c["panel"] as Control).visible = k < shown + var open := k == 0 + for r: Control in c["rich"]: + r.visible = open and bool(r.get_meta("has", true)) + (c["headline"] as Label).max_lines_visible = 2 if open else 1 + if not open: + c["age"] = FADE + FLASH + (c["panel"] as Control).modulate.a = 1.0 + (c["box"] as StyleBoxFlat).bg_color = FOLDED_BG + _line_box.visible = not _line.text.is_empty() and mode != "hidden" + _title.modulate.a = 0.55 if mode == "hidden" else 1.0 + + +func _process(delta: float) -> void: + _pulse += delta + if _line_pulse and _line_box.visible: + _line.modulate.a = 0.6 + 0.4 * absf(sin(_pulse * 2.4)) + for c: Dictionary in _cards: + var age := float(c["age"]) + if age >= FADE + FLASH: + continue + age = minf(age + delta, FADE + FLASH) + c["age"] = age + (c["panel"] as Control).modulate.a = clampf(age / FADE, 0.0, 1.0) + var k := 1.0 - clampf((age - FADE) / FLASH, 0.0, 1.0) + var col: Color = c["item"]["color"] + var flash := Color(col.r, col.g, col.b, 0.42) + (c["box"] as StyleBoxFlat).bg_color = OPEN_BG.lerp(flash, k * 0.5) + + +func _card(item: Dictionary) -> Dictionary: + var col: Color = item.get("color", S.ACCENT) + var p := PanelContainer.new() + p.name = "Card" + p.mouse_filter = Control.MOUSE_FILTER_IGNORE + var sb := S.stripe(S.box(OPEN_BG, S.RADIUS, 12, 7), col, 3) + p.add_theme_stylebox_override("panel", sb) + var v := VBoxContainer.new() + v.mouse_filter = Control.MOUSE_FILTER_IGNORE + v.add_theme_constant_override("separation", 3) + p.add_child(v) + var top := _row(6) + top.name = "Top" + v.add_child(top) + var ago := _top_row(top, item, col) + var head := _wrapped(str(item.get("headline", "")), 15, S.WHITE, S.bold(), "Headline", 2) + v.add_child(head) + var rich: Array[Control] = [] + var detail := str(item.get("detail", "")) + var dl := _wrapped("“%s”" % detail, 13, S.TEXT_DIM, S.font(), "Detail", 3) + dl.set_meta("has", not detail.is_empty()) + rich.append(dl) + var extra := str(item.get("extra", "")) + var el := _wrapped(extra, 12, col.lerp(S.WHITE, 0.3), S.bold(), "Politics", 2) + el.set_meta("has", not extra.is_empty()) + rich.append(el) + var foot := _footer(item) + rich.append(foot) + for r: Control in rich: + v.add_child(r) + return { + "item": item, + "panel": p, + "box": sb, + "headline": head, + "ago": ago, + "where": foot.get_node("Where"), + "rich": rich, + "age": 0.0, + } + + +## Who decided (clipped to fit) and the model badge, then the age at the far end. Returns the +## age label. +func _top_row(top: HBoxContainer, item: Dictionary, col: Color) -> Label: + var who_t := str(item.get("who", "")) + var who := S.caps_label(who_t, 13, col.lerp(S.WHITE, 0.45), 0) + who.name = "Who" + who.clip_text = true + who.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + top.add_child(who) + var room := INNER - AGO_W - 12.0 + var model := str(item.get("model", "")) + if not model.is_empty(): + var b := S.badge(model, col) + b.name = "Model" + top.add_child(b) + room -= b.get_combined_minimum_size().x + who.custom_minimum_size.x = clampf(S.caps_width(who_t, 13) + 2.0, 0.0, maxf(room, 40.0)) + top.add_child(_fill()) + var ago := S.plain("", 12, S.TEXT_FAINT, S.font()) + ago.name = "Ago" + top.add_child(ago) + return ago + + +## Where the answer came from, where it happened and, for a decision with a place, the G key +## that routes the GPS there. +func _footer(item: Dictionary) -> HBoxContainer: + var foot := _row(6) + foot.name = "Footer" + var tag := W.source_tag(str(item.get("source", "")), bool(item.get("failed", false))) + var sbadge := S.badge(tag, TAG_COLORS.get(tag, S.AMBER), 10) + sbadge.name = "Source" + foot.add_child(sbadge) + var where := S.plain("", 12, S.TEXT_FAINT, S.font()) + where.name = "Where" + where.clip_text = true + where.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + where.size_flags_horizontal = Control.SIZE_EXPAND_FILL + foot.add_child(where) + var route := _row(4) + route.name = "Route" + var key := S.key_cap("G", 10) + key.size_flags_vertical = Control.SIZE_SHRINK_CENTER + route.add_child(key) + route.add_child(S.caps_label("Route", 10, S.TEXT_DIM, 0)) + route.visible = bool(item.get("placed", false)) + foot.add_child(route) + return foot + + +func _wrapped(t: String, size_px: int, col: Color, f: Font, n: String, lines: int) -> Label: + var l := S.plain(t, size_px, col, f) + l.name = n + l.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + l.custom_minimum_size = Vector2(INNER, 0) + l.max_lines_visible = lines + return l + + +func _row(sep: int) -> HBoxContainer: + var h := HBoxContainer.new() + h.mouse_filter = Control.MOUSE_FILTER_IGNORE + h.add_theme_constant_override("separation", sep) + return h + + +func _fill() -> Control: + var gap := Control.new() + gap.mouse_filter = Control.MOUSE_FILTER_IGNORE + gap.size_flags_horizontal = Control.SIZE_EXPAND_FILL + return gap diff --git a/scripts/fp/activity/activity_feed.gd.uid b/scripts/fp/activity/activity_feed.gd.uid new file mode 100644 index 00000000..1c092a79 --- /dev/null +++ b/scripts/fp/activity/activity_feed.gd.uid @@ -0,0 +1 @@ +uid://bs5gu8qvmecq2 diff --git a/scripts/fp/activity/activity_tags.gd b/scripts/fp/activity/activity_tags.gd new file mode 100644 index 00000000..e14c9286 --- /dev/null +++ b/scripts/fp/activity/activity_tags.gd @@ -0,0 +1,105 @@ +extends Control +## Floating tags over the AI beacons (feature "activity"): for each beacon in front of the camera +## and within RANGE, who decided and what, with the distance, pinned above the ground at the beacon +## (lower when near, so a close beacon's tag stays in view). Nearest first; a tag that would leave +## the screen, cover a HUD area the caller keeps clear or overlap a placed tag is left out instead +## of being pushed to the edge. Hidden with the HUD, under modals and in photo mode. + +const S := preload("res://scripts/fp/ui/hud_style.gd") +const WS := preload("res://scripts/fp/world_scale.gd") + +## Tiles (world units) within which a beacon gets a tag. +const RANGE := 30.0 +## Height of a tag over its beacon's foot: LIFT_PER of the distance, between LIFT_MIN and LIFT. +const LIFT := 14.0 * WS.M +const LIFT_MIN := 2.5 * WS.M +const LIFT_PER := 0.3 +const MAX_TAGS := 4 +const SAFE := 24.0 +const GAP := 4.0 + +## Pool of {"panel": PanelContainer, "who": Label, "what": Label}. +var _pool: Array[Dictionary] = [] +var _shown: Array[String] = [] + + +func setup() -> void: + name = "ActivityTags" + mouse_filter = Control.MOUSE_FILTER_IGNORE + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + + +## entries: [{"key", "pos": Vector3 (beacon foot), "who", "what", "color": Color}], me: the +## player's position, avoid: rects (in this control's coordinates) no tag may cover. Places up to +## MAX_TAGS tags for cam; [] or a null cam hides them. +func show_tags(entries: Array, cam: Camera3D, me: Vector3, avoid: Array[Rect2] = []) -> void: + _shown.clear() + var view := size if size.x > 0.0 else get_viewport_rect().size + var screen := Rect2(Vector2(SAFE, SAFE), view - Vector2(SAFE, SAFE) * 2.0) + var near: Array = [] + if cam != null and cam.is_inside_tree(): + for e: Dictionary in entries: + var p: Vector3 = e["pos"] + var d := Vector2(p.x - me.x, p.z - me.z).length() + var top := p + Vector3.UP * clampf(d * LIFT_PER, LIFT_MIN, LIFT) + if d <= RANGE and not cam.is_position_behind(top): + near.append([d, e, cam.unproject_position(top)]) + near.sort_custom(func(a: Array, b: Array) -> bool: return float(a[0]) < float(b[0])) + var placed: Array[Rect2] = avoid.duplicate() + var k := 0 + for n: Array in near: + if k >= MAX_TAGS: + break + var e: Dictionary = n[1] + var t := _tag(k) + var color: Color = e["color"] + var who: Label = t["who"] + who.text = str(e["who"]) + # Re-applying an equal override still re-shapes the label, and this runs every frame. + var tint := color.lerp(S.WHITE, 0.4) + if who.get_theme_color("font_color") != tint: + who.add_theme_color_override("font_color", tint) + (t["what"] as Label).text = "%s · %s" % [str(e["what"]), S.distance(float(n[0]))] + var p: PanelContainer = t["panel"] + var sz := p.get_combined_minimum_size() + p.size = sz + var at: Vector2 = n[2] - Vector2(sz.x * 0.5, sz.y) + var r := Rect2(at, sz).grow(GAP) + var hit := placed.any(func(q: Rect2) -> bool: return q.intersects(r)) + if hit or not screen.encloses(Rect2(at, sz)): + continue + placed.append(r) + p.position = at.round() + p.visible = true + var box: StyleBoxFlat = t["box"] + if box.border_color != color: + box.border_color = color + _shown.append(str(e["key"])) + k += 1 + for j in range(k, _pool.size()): + (_pool[j]["panel"] as Control).visible = false + + +## Keys of the beacons tagged on screen now (tests). +func shown() -> Array[String]: + return _shown + + +func _tag(k: int) -> Dictionary: + while _pool.size() <= k: + var p := PanelContainer.new() + p.mouse_filter = Control.MOUSE_FILTER_IGNORE + var sb := S.stripe(S.box(Color(0.03, 0.04, 0.07, 0.62), S.RADIUS, 8, 4), S.ACCENT, 2) + p.add_theme_stylebox_override("panel", sb) + var v := VBoxContainer.new() + v.mouse_filter = Control.MOUSE_FILTER_IGNORE + v.add_theme_constant_override("separation", 0) + p.add_child(v) + var who := S.caps_label("", 11, S.WHITE, 2) + v.add_child(who) + var what := S.label("", 13, S.TEXT, S.bold(), 2) + v.add_child(what) + p.visible = false + add_child(p) + _pool.append({"panel": p, "box": sb, "who": who, "what": what}) + return _pool[k] diff --git a/scripts/fp/activity/activity_tags.gd.uid b/scripts/fp/activity/activity_tags.gd.uid new file mode 100644 index 00000000..b74bfe5a --- /dev/null +++ b/scripts/fp/activity/activity_tags.gd.uid @@ -0,0 +1 @@ +uid://bxx50edf5660x diff --git a/scripts/fp/activity/activity_words.gd b/scripts/fp/activity/activity_words.gd new file mode 100644 index 00000000..4d51959d --- /dev/null +++ b/scripts/fp/activity/activity_words.gd @@ -0,0 +1,204 @@ +extends RefCounted +## Plain words for the AI activity layer: what a mayor's turn did ("Zoned 6 homes · Ordered a +## Solar Farm"), council, chaos and grid rulings, a support agent's model badge, where an answer +## came from (live, replay or offline), "4 min" ages and compass directions. No nodes. + +const D := preload("res://scripts/sim/sim_defs.gd") + +const ZONE_WORDS := { + "R": ["home", "homes"], "C": ["shop lot", "shop lots"], "I": ["factory lot", "factory lots"] +} +const DIRS := ["N", "NE", "E", "SE", "S", "SW", "W", "NW"] +const SOURCE_TAGS := {"live": "LIVE", "cache": "REPLAY"} +## Most action phrases one summary lists before "+N more". +const MAX_PARTS := 2 +const DETAIL_CHARS := 150 + + +## The model a mayor runs on, as its badge reads ("DeepSeek-V4"). +static func mayor_model(mi: int) -> String: + return str(D.MAYORS[mi]["name"]) if mi >= 0 and mi < D.MAYORS.size() else "" + + +## A model id as a badge: the part after the vendor, without a trailing date stamp +## ("openai/gpt-6-luna" -> "gpt-6-luna", +## "deepseek-ai/DeepSeek-V4-Flash-0731" -> "DeepSeek-V4-Flash"). +static func model_label(model_id: String) -> String: + var s := model_id.get_slice("/", model_id.get_slice_count("/") - 1) + return RegEx.create_from_string("-\\d{4}$").sub(s, "") + + +## Where an answer came from: "LIVE" (a model call), "REPLAY" (a recorded model answer), +## "OFFLINE" (the built-in planner, no model involved) or, when a live call was tried and failed +## (failed), "FALLBACK". +static func source_tag(source: String, failed: bool = false) -> String: + if failed and source != "live": + return "FALLBACK" + return str(SOURCE_TAGS.get(source, "OFFLINE")) + + +## One line for what a mayor's turn did, from CityBrain's applied list +## ([{"action": Dictionary, "ok": bool, "reason": String}]). +static func summarize(applied: Array) -> String: + var parts: Array[String] = [] + var refused := 0 + for v: Variant in applied: + if typeof(v) != TYPE_DICTIONARY: + continue + var e: Dictionary = v + if not bool(e.get("ok", false)): + refused += 1 + continue + var p := action_words(e.get("action", {})) + if not p.is_empty() and not parts.has(p): + parts.append(p) + if parts.is_empty(): + return "Held off this turn" if refused == 0 else "Every order was refused" + var out := " · ".join(PackedStringArray(parts.slice(0, MAX_PARTS))) + if parts.size() > MAX_PARTS: + out += " · +%d more" % (parts.size() - MAX_PARTS) + if refused > 0: + out += " (%d refused)" % refused + return out + + +## The first refusal reason in an applied list ("cannot afford road ($120)"), or "". +static func first_refusal(applied: Array) -> String: + for v: Variant in applied: + if typeof(v) == TYPE_DICTIONARY and not bool((v as Dictionary).get("ok", false)): + return str((v as Dictionary).get("reason", "")) + return "" + + +## A short phrase for one mayor action ({"type": "zone", "zone": "R", "w": 3, "h": 2} -> +## "Zoned 6 homes"), or "" for an unknown one. +static func action_words(a: Variant) -> String: + if typeof(a) != TYPE_DICTIONARY: + return "" + var d: Dictionary = a + var out := "" + match str(d.get("type", "")).to_lower(): + "road": + var dx := absi(int(d.get("x2", d.get("x", 0))) - int(d.get("x", 0))) + var dy := absi(int(d.get("y2", d.get("y", 0))) - int(d.get("y", 0))) + var n := mini(dx + dy + 1, D.MAX_ROAD_LEN) + out = "Laid %d road tile%s" % [n, "" if n == 1 else "s"] + "zone": + var z := str(d.get("zone", "R")).to_upper().left(1) + var w: Array = ZONE_WORDS.get(z, ZONE_WORDS["R"]) + var side := D.MAX_ZONE_SIDE + var n := clampi(int(d.get("w", 1)), 1, side) * clampi(int(d.get("h", 1)), 1, side) + out = "Zoned %d %s" % [n, w[0] if n == 1 else w[1]] + "build": + var b: Dictionary = D.BUILDINGS.get(str(d.get("building", "")).to_lower(), {}) + out = "Ordered " + _a(str(b.get("label", "building"))) + "bulldoze": + var n := clampi(int(d.get("w", 1)), 1, 3) * clampi(int(d.get("h", 1)), 1, 3) + out = "Cleared %d lot%s" % [n, "" if n == 1 else "s"] + "set_tax", "tax": + var r := [int(d.get("r", 0)), int(d.get("c", 0)), int(d.get("i", 0))] + out = "Set taxes to R%d/C%d/I%d" % r + "emergency": + out = "Sent emergency crews" + "grant": + var to := D.mayor_display(D.mayor_index(str(d.get("to", "")))) + var amt := "$" + str(maxi(0, int(d.get("amount", 0)))) + out = "Granted %s to %s" % [amt, to] if not to.is_empty() else "Made a grant" + "annex": + out = "Annexed new land" + return out + + +## Proposals and votes from a decision entry ("Proposed the Curfew ordinance · Voted no on +## Clean Air Act"), or "". +static func politics(entry: Dictionary) -> String: + var parts: Array[String] = [] + var prop := str(entry.get("proposal", "")) + if D.ORDINANCES.has(prop): + parts.append("Proposed the %s ordinance" % str(D.ORDINANCES[prop]["label"])) + var votes: Variant = entry.get("votes", {}) + if typeof(votes) == TYPE_DICTIONARY: + for key: Variant in (votes as Dictionary).keys(): + var o: Dictionary = D.ORDINANCES.get(str(key), {}) + if not o.is_empty() and str(key) != prop: + parts.append("Voted %s on %s" % [str(votes[key]), str(o["label"])]) + return " · ".join(PackedStringArray(parts.slice(0, 2))) + + +## What a council ruling did ("Passed Curfew · Repealed Clean Air Act"). +static func council_words(r: Dictionary) -> String: + var parts: Array[String] = [] + for pair: Array in [["enacted", "Passed"], ["repealed", "Repealed"], ["rejected", "Rejected"]]: + var arr: Variant = r.get(pair[0], []) + if typeof(arr) != TYPE_ARRAY: + continue + for k: Variant in arr: + var o: Dictionary = D.ORDINANCES.get(str(k), {}) + if not o.is_empty(): + parts.append("%s %s" % [pair[1], str(o["label"])]) + if parts.is_empty(): + return "No ordinance changed" + return " · ".join(PackedStringArray(parts.slice(0, MAX_PARTS + 1))) + + +## What the Chaos Director chose ("Set off a building fire" or "Kept the city calm"). +static func chaos_words(d: Dictionary) -> String: + if not bool(d.get("trigger", false)): + return "Kept the city calm" + var ev: Dictionary = D.DISASTERS.get(str(d.get("type", "")), {}) + return "Set off " + _a(str(ev.get("label", "an incident")).to_lower()) + + +## Which district the Grid Operator powers first ("Power goes first to Central Plaza"). +static func operator_words(d: Dictionary) -> String: + var pr: Variant = d.get("priority", []) + if typeof(pr) != TYPE_ARRAY or (pr as Array).is_empty(): + return "Kept the power order" + var first := int((pr as Array)[0]) + if first < 0 or first >= D.MAYORS.size(): + return "Kept the power order" + return "Power goes first to " + str(D.MAYORS[first]["district"]) + + +## Free text for a card: de-branded, one line, at most DETAIL_CHARS with an ellipsis. +static func detail(t: String) -> String: + var s := D.debrand(t.strip_edges()).replace("\n", " ") + if s.length() <= DETAIL_CHARS: + return s + return s.left(DETAIL_CHARS - 1).rstrip(" ,.;:") + "…" + + +## A saved focus tile back as a Vector2i: CityBrain keeps a Vector2i, a JSON save turns it into +## "(x, y)" or [x, y]. D.NO_TILE when there is none. +static func tile_of(v: Variant) -> Vector2i: + if typeof(v) == TYPE_VECTOR2I: + return v + if typeof(v) == TYPE_ARRAY and (v as Array).size() >= 2: + return Vector2i(int(v[0]), int(v[1])) + if typeof(v) == TYPE_STRING: + var m := RegEx.create_from_string("(-?\\d+)\\s*,\\s*(-?\\d+)").search(str(v)) + if m != null: + return Vector2i(int(m.get_string(1)), int(m.get_string(2))) + return D.NO_TILE + + +## Compass direction from a to b on the ground ("NE"), north being -Z. +static func direction(a: Vector3, b: Vector3) -> String: + var bearing := atan2(b.x - a.x, -(b.z - a.z)) + return DIRS[posmod(roundi(bearing / (PI / 4.0)), 8)] + + +## A card's age: "now", "4 min", "2 h"; "Day 7" for one restored from a save (seconds < 0). +static func ago(seconds: float, day: int) -> String: + if seconds < 0.0: + return "Day %d" % day + if seconds < 45.0: + return "now" + if seconds < 3600.0: + return "%d min" % maxi(1, roundi(seconds / 60.0)) + return "%d h" % roundi(seconds / 3600.0) + + +static func _a(noun: String) -> String: + var art := "an" if noun.left(1).to_lower() in ["a", "e", "i", "o", "u"] else "a" + return "%s %s" % [art, noun] diff --git a/scripts/fp/activity/activity_words.gd.uid b/scripts/fp/activity/activity_words.gd.uid new file mode 100644 index 00000000..3e570086 --- /dev/null +++ b/scripts/fp/activity/activity_words.gd.uid @@ -0,0 +1 @@ +uid://s8vn0gypiy61 diff --git a/scripts/fp/fp_features.gd b/scripts/fp/fp_features.gd index 7f7b85bf..6053b149 100644 --- a/scripts/fp/fp_features.gd +++ b/scripts/fp/fp_features.gd @@ -14,4 +14,8 @@ const FEATURES := [ {"id": "overview", "slice": "integration", "path": "res://scripts/fp/overview/overview_feature.gd"}, {"id": "drive", "slice": "integration", "path": "res://scripts/fp/drive/drive_feature.gd"}, {"id": "phone", "slice": "integration", "path": "res://scripts/fp/phone/phone_feature.gd"}, + { + "id": "activity", "slice": "integration", + "path": "res://scripts/fp/activity/activity_feature.gd", + }, ] diff --git a/scripts/fp/game_bus.gd b/scripts/fp/game_bus.gd index 258403c4..e13ce009 100644 --- a/scripts/fp/game_bus.gd +++ b/scripts/fp/game_bus.gd @@ -3,7 +3,8 @@ extends Node ## Signals that cross first-person slices. Emitters and listeners are listed in docs/fp/S0-foundation.md §9. ## A world point that deserves attention (y = 0). reason: "event:", "resident:", "tile", -## "district:" or "player" (a waypoint set on the map). +## "district:", "ai:" (an AI decision landed there) or "player" (a +## waypoint set on the map). signal ping(world_pos: Vector3, reason: String) ## A player-facing notice. kind: "info", "alert", "success" or "system". signal toast(text: String, kind: String) diff --git a/scripts/fp/ui/fp_hud.gd b/scripts/fp/ui/fp_hud.gd index 35a3a0fc..a0d03109 100644 --- a/scripts/fp/ui/fp_hud.gd +++ b/scripts/fp/ui/fp_hud.gd @@ -234,6 +234,9 @@ func _on_ping(world_pos: Vector3, reason: String) -> void: pings.append({"pos": Vector3(world_pos.x, 0.0, world_pos.z), "reason": reason, "age": 0.0, "alpha": 1.0}) while pings.size() > MAX_PINGS: pings.pop_front() + # The activity feed already announces an AI decision; the marker is enough here. + if reason.begins_with("ai:"): + return var where := _place_near(world_pos) if reason.begins_with("event:"): var t := reason.substr(6) diff --git a/scripts/fp/ui/hud_compass.gd b/scripts/fp/ui/hud_compass.gd index 830bd0d5..416dfa4c 100644 --- a/scripts/fp/ui/hud_compass.gd +++ b/scripts/fp/ui/hud_compass.gd @@ -100,7 +100,7 @@ func _collect() -> void: var e: Dictionary = pings[k] var p: Vector3 = e["pos"] var is_event := str(e["reason"]).begins_with("event:") - markers.append({"bearing": bearing_to(me, p), "dist": S.distance(_flat(me, p)), "color": S.RED if is_event else S.AMBER, "kind": "event" if is_event else "ping", "alpha": float(e.get("alpha", 1.0))}) + markers.append({"bearing": bearing_to(me, p), "dist": S.distance(_flat(me, p)), "color": S.ping_color(str(e["reason"])), "kind": "event" if is_event else "ping", "alpha": float(e.get("alpha", 1.0))}) n += 1 diff --git a/scripts/fp/ui/hud_style.gd b/scripts/fp/ui/hud_style.gd index df2aed42..43e6d1bd 100644 --- a/scripts/fp/ui/hud_style.gd +++ b/scripts/fp/ui/hud_style.gd @@ -8,6 +8,8 @@ const WS := preload("res://scripts/fp/world_scale.gd") ## Every Label, RichTextLabel and Button slice B creates carries this meta (the branding test reads it). const OWNER_META := "fp_owner" const OWNER := "hud_widgets" +## Meta on model and source badges (badge()): the only street text allowed to name a model. +const BADGE_META := "fp_model_badge" const WHITE := Color(1, 1, 1) const TEXT := Color(0.96, 0.97, 0.99) @@ -146,6 +148,43 @@ static func stripe(sb: StyleBoxFlat, col: Color, w: int = 3) -> StyleBoxFlat: return sb +## A small tag that names a model or where an answer came from ("DeepSeek-V4", "LIVE"). Its label +## carries BADGE_META instead of OWNER_META: badges are the one place street text names a model, +## so the no-branding check over owned text (owned_texts) leaves them out. +static func badge(t: String, col: Color, size_px: int = 11) -> PanelContainer: + var p := PanelContainer.new() + p.mouse_filter = Control.MOUSE_FILTER_IGNORE + var bg := Color(col.r, col.g, col.b, 0.16) + var sb := box(bg, RADIUS, 5, 1, Color(col.r, col.g, col.b, 0.6), 1) + p.add_theme_stylebox_override("panel", sb) + var l := Label.new() + l.text = t + l.uppercase = true + l.mouse_filter = Control.MOUSE_FILTER_IGNORE + l.add_theme_font_override("font", caps()) + l.add_theme_font_size_override("font_size", size_px) + l.add_theme_color_override("font_color", col.lerp(WHITE, 0.4)) + l.set_meta(BADGE_META, true) + p.add_child(l) + p.size_flags_vertical = Control.SIZE_SHRINK_CENTER + return p + + +## Marker colour for a bus ping reason: incidents red, AI decisions in the deciding agent's colour +## ("ai:1" a mayor, "ai:council" a support agent), anything else amber. +static func ping_color(reason: String) -> Color: + if reason.begins_with("event:"): + return RED + if reason.begins_with("ai:"): + var who := reason.substr(3) + if who.is_valid_int() and int(who) >= 0 and int(who) < D.MAYORS.size(): + return Color(str(D.MAYORS[int(who)]["color"])) + if D.SUPPORT_AGENTS.has(who): + return Color(str(D.SUPPORT_AGENTS[who]["color"])) + return ACCENT + return AMBER + + ## A StyleBoxFlat with one corner radius and content margins (h, v). static func box( bg: Color, radius: int, h: int, v: int, border: Color = Color(0, 0, 0, 0), bw: int = 0 diff --git a/scripts/fp/ui/minimap_view.gd b/scripts/fp/ui/minimap_view.gd index db688674..3bd84a94 100644 --- a/scripts/fp/ui/minimap_view.gd +++ b/scripts/fp/ui/minimap_view.gd @@ -339,7 +339,7 @@ func _draw_overlay() -> void: if _near_incident(w): continue var a := float(e.get("alpha", 1.0)) - var col := S.RED if str(e["reason"]).begins_with("event:") else S.AMBER + var col := S.ping_color(str(e["reason"])) var q := _to_overlay(w, true) o.draw_arc(q, 6.0, 0.0, TAU, 32, Color(col, 0.9 * a), 2.0, true) o.draw_circle(q, 2.4, Color(col, a), true, -1.0, true) diff --git a/scripts/fp/ui/pause_menu.gd b/scripts/fp/ui/pause_menu.gd index d2003533..5d6ea673 100644 --- a/scripts/fp/ui/pause_menu.gd +++ b/scripts/fp/ui/pause_menu.gd @@ -55,7 +55,7 @@ const LEGEND := [ ] const KEYS := [ ["M", "Map"], ["Tab", "City overview"], ["V", "Camera view"], ["Q", "Shoulder"], ["E", "Interact"], ["F", "Get in / out of a car"], ["B", "Build phone"], - ["Enter", "Chat"], + ["Enter", "Chat"], ["J", "AI activity"], ["G", "Route to AI decision"], ["[ ]", "Game speed"], ["T", "Skip to morning"], ["P", "Photo mode"], ["F1", "Hide HUD"], ["F5", "Quick save"], ["F9", "Quick load"], ] diff --git a/tools/tests/fp_activity_test.gd b/tools/tests/fp_activity_test.gd new file mode 100644 index 00000000..b128ef28 --- /dev/null +++ b/tools/tests/fp_activity_test.gd @@ -0,0 +1,598 @@ +extends SceneTree +## AI activity (integration feature "activity"). Every mayor turn, council ruling, Chaos Director +## pick, grid order and Gazette front page lands as a feed card: who decided, the model badge, +## where the answer came from, what and why, and where. A decision with a place raises a beacon and +## pings the map in the agent's colour without a toast. J cycles the feed, G routes to the newest +## decision with a place. On foot the mayors' turns are spread over the day. Modals hide the feed; +## save, load and teardown. + +const K := preload("res://tools/tests/fp_kit.gd") +const D := preload("res://scripts/sim/sim_defs.gd") +const S := preload("res://scripts/fp/ui/hud_style.gd") +const W := preload("res://scripts/fp/activity/activity_words.gd") +const MT := preload("res://scripts/ai/mayor_turns.gd") +const DT := 1.0 / 60.0 +## Brand names and system words that owned feed text must never show (badges are exempt). +const LEAK_RE := "(?i)(Argon|DeepSeek|Kimi|Qwen|Grok|GMI|gemini|MiniMax|latency|cache|heuristic)" + +var fails: Array[String] = [] +var decided: Array[int] = [] + + +func _init() -> void: + await process_frame + var ctx := K.make_ctx(get_root(), {"grow": true, "camera_mode": "third"}) + ctx.player.set_process(false) + K.make_feature(ctx, "hud_widgets") + var f: FpFeature = K.make_feature(ctx, "activity") + K.check(fails, f != null and f.feature_id() == "activity", "the activity feature is registered") + if f == null: + K.finish(self, "FP_ACTIVITY_TEST", fails) + return + f.set_process(false) + ctx.brain.mayor_decided.connect(func(mi: int, _e: Dictionary) -> void: decided.append(mi)) + _check_words() + _check_setup(ctx, f) + _check_turns(ctx, f) + _check_mayor_card(ctx, f) + _check_support(ctx, f) + _check_branding(f) + _check_modes(ctx, f) + _check_route(ctx, f) + _check_tags(ctx, f) + _check_cover(ctx, f) + _check_live_state(ctx, f) + _check_save(ctx, f) + _check_teardown(ctx, f) + K.finish(self, "FP_ACTIVITY_TEST", fails) + + +func _check_words() -> void: + var a := W.model_label("openai/gpt-6-luna") + var b := W.model_label("deepseek-ai/DeepSeek-V4-Flash-0731") + K.check( + fails, + a == "gpt-6-luna" and b == "DeepSeek-V4-Flash", + "model ids as badges (%s, %s)" % [a, b] + ) + var mm := W.mayor_model(1) == str(D.MAYORS[1]["name"]) and W.mayor_model(9).is_empty() + K.check(fails, mm, "a mayor's badge is the model it runs on") + var tags := [ + W.source_tag("live"), + W.source_tag("cache"), + W.source_tag("heuristic"), + W.source_tag("heuristic", true), + W.source_tag("cache", true), + ] + var want := ["LIVE", "REPLAY", "OFFLINE", "FALLBACK", "FALLBACK"] + K.check(fails, tags == want, "answers read LIVE, REPLAY, OFFLINE or FALLBACK (%s)" % str(tags)) + var applied := [ + {"action": {"type": "zone", "zone": "R", "x": 3, "y": 3, "w": 3, "h": 2}, "ok": true}, + {"action": {"type": "build", "building": "fire_station", "x": 4, "y": 4}, "ok": true}, + {"action": {"type": "road", "x": 1, "y": 1, "x2": 6, "y2": 1}, "ok": true}, + {"action": {"type": "road", "x": 1, "y": 1, "x2": 1}, "ok": false, "reason": "no money"}, + ] + var s := W.summarize(applied) + var said := "Zoned 6 homes · Ordered a Fire Station · +1 more (1 refused)" + K.check(fails, s == said, "a turn reads as plain words (%s)" % s) + var held := W.summarize([]) == "Held off this turn" + K.check(fails, held and W.first_refusal(applied) == "no money", "an empty turn holds off") + var ages := [W.ago(10.0, 3), W.ago(250.0, 3), W.ago(7300.0, 3), W.ago(-1.0, 7)] + K.check(fails, ages == ["now", "4 min", "2 h", "Day 7"], "card ages (%s)" % str(ages)) + var ne := W.direction(Vector3.ZERO, Vector3(5, 0, -5)) + var so := W.direction(Vector3.ZERO, Vector3(0, 0, 4)) + K.check(fails, ne == "NE" and so == "S", "compass directions, north is -Z (%s, %s)" % [ne, so]) + var tiles := [ + W.tile_of(Vector2i(3, 4)), W.tile_of("(5, 6)"), W.tile_of([7, 8]), W.tile_of(null) + ] + var tiles_ok := tiles == [Vector2i(3, 4), Vector2i(5, 6), Vector2i(7, 8), D.NO_TILE] + K.check(fails, tiles_ok, "focus tiles survive a JSON save (%s)" % str(tiles)) + var long := W.detail("DeepSeek-V4 says " + "very ".repeat(60) + "much") + var clipped := long.length() <= W.DETAIL_CHARS and long.ends_with("…") + K.check(fails, clipped and not long.contains("DeepSeek"), "free text is de-branded and clipped") + var pol := W.politics({"proposal": "curfew", "votes": {"clean_air_act": "no", "curfew": "yes"}}) + var pol_want := "Proposed the Youth Curfew ordinance · Voted no on Clean Air Act" + K.check(fails, pol == pol_want, "proposals and votes (%s)" % pol) + + +func _check_setup(ctx: FpContext, f: FpFeature) -> void: + var feed: Control = f.get("feed") + K.check( + fails, + feed != null and feed.get_parent() == ctx.hud.slot("center"), + "the feed is on the HUD" + ) + K.check(fails, ctx.brain.stagger_mayors, "on foot the mayors' turns are staggered") + var st := f.self_test() + K.check(fails, bool(st.get("ok", false)), "self_test ok (%s)" % str(st.get("detail", ""))) + var j := false + var clash: Array[String] = [] + for a: StringName in InputMap.get_actions(): + for e: InputEvent in InputMap.action_get_events(a): + if not (e is InputEventKey and (e as InputEventKey).physical_keycode == KEY_J): + continue + if a == &"ai_feed": + j = true + else: + clash.append(str(a)) + K.check(fails, j and clash.is_empty(), "J is ai_feed's alone (others: %s)" % str(clash)) + + +func _check_turns(ctx: FpContext, f: FpFeature) -> void: + var o := MT.order(3) + var sorted := o.duplicate() + sorted.sort() + K.check( + fails, o[0] == 3 and sorted == [0, 1, 2, 3, 4], "turn order rotates daily (%s)" % str(o) + ) + var spread := MT.at(0) > 0.0 and MT.at(1) > MT.at(0) and MT.at(4) < MT.SPAN + K.check(fails, spread, "the turns are spread over the day") + var brain := ctx.brain + _next_day(ctx) + var day := ctx.sim.day + var order := MT.order(day) + K.check( + fails, brain.turns.due == order, "a new day queues five turns (%s)" % str(brain.turns.due) + ) + var feed: Control = f.get("feed") + f.call("_refresh") + var line := str(feed.call("line_text")) + var next_ok := line.begins_with("Next up: " + D.mayor_display(order[0])) + K.check(fails, next_ok, "the feed says who goes next (%s)" % line) + var n0 := decided.size() + brain._process(0.0) + K.check(fails, decided.size() == n0, "nobody decides at dawn") + ctx.sim.step_tick() + brain._process(0.0) + var first_ok := decided.size() == n0 + 1 and decided[-1] == order[0] + K.check(fails, first_ok, "the first mayor decides a quarter into the day") + var items: Array = f.get("items") + var newest: Dictionary = items[-1] if not items.is_empty() else {} + var card_ok := str(newest.get("agent", "")) == str(order[0]) + K.check(fails, card_ok, "and lands in the feed (%s)" % str(newest.get("headline", ""))) + K.check(fails, brain.turns.due.size() == 4, "four turns still wait") + _next_day(ctx) + var all_in := decided.size() - n0 == 5 and decided.slice(n0) == order + K.check(fails, all_in, "every mayor decides before the day ends (%s)" % str(decided.slice(n0))) + brain.stagger_mayors = false + brain.call("_day_gate") + var n1 := decided.size() + _next_day(ctx) + var dawn := decided.size() - n1 == 5 and brain.turns.due.is_empty() + K.check(fails, dawn, "without the stagger (orbit) all five decide at dawn") + brain.stagger_mayors = true + + +func _check_mayor_card(ctx: FpContext, f: FpFeature) -> void: + var feed: Control = f.get("feed") + var beacons: Node3D = f.get("beacons") + var mi := 2 + var t := _lot_tile(ctx) + f.call("_on_mayor_thinking", mi) + var line := str(feed.call("line_text")) + var who_now := "%s is deciding…" % D.mayor_display(mi) + K.check(fails, line == who_now, "the feed shows who is deciding (%s)" % line) + ctx.hud.toasts.clear() + ctx.hud.pings.clear() + var acts := [ + {"action": {"type": "zone", "zone": "R", "x": t.x, "y": t.y, "w": 3, "h": 2}, "ok": true}, + {"action": {"type": "road", "x": 1, "y": 1, "x2": 3}, "ok": false, "reason": "no money"}, + ] + var entry := { + "day": ctx.sim.day, + "thought": "DeepSeek-V4 wants homes by the river.", + "post": "", + "actions": acts, + "source": "live", + "error": "", + "focus": t, + "proposal": "curfew", + "votes": {"clean_air_act": "no"}, + } + f.call("_on_mayor_decided", mi, entry) + var card: Control = feed.call("open_card") + var head := _text(card, "Headline") + K.check( + fails, head == "Zoned 6 homes (1 refused)", "the newest card says what they did (%s)" % head + ) + var who := _text(card, "Who") + var model := _badge(card, "Model") + var named := who == D.mayor_display(mi) and model == str(D.MAYORS[mi]["name"]) + K.check(fails, named, "it names the mayor and their model (%s, %s)" % [who, model]) + K.check(fails, _badge(card, "Source") == "LIVE", "and that the answer came live") + var detail := _text(card, "Detail") + var why_ok := detail.contains("homes by the river") and not detail.contains("DeepSeek") + K.check(fails, why_ok, "why, de-branded (%s)" % detail) + var pol := _text(card, "Politics") + K.check(fails, pol.begins_with("Proposed the Youth Curfew"), "and their politics (%s)" % pol) + var where := _text(card, "Where") + var routed := (card.find_child("Route", true, false) as Control).visible + K.check(fails, not where.is_empty() and routed, "where, with the G hint (%s)" % where) + var key := "mayor:%d" % mi + var fresh := ( + bool(beacons.call("has_beacon", key)) and float(beacons.call("freshness", key)) > 0.9 + ) + K.check(fails, fresh, "a fresh beacon rises over the place") + var bp: Vector3 = beacons.call("position_of", key) + K.check(fails, Vector2(bp.x - t.x, bp.z - t.y).length() < 0.01, "at the focus tile") + var reason := str(ctx.hud.pings[-1]["reason"]) if not ctx.hud.pings.is_empty() else "" + K.check(fails, reason == "ai:%d" % mi, "it pings the minimap and compass (%s)" % reason) + var tint := S.ping_color(reason).is_equal_approx(Color(str(D.MAYORS[mi]["color"]))) + K.check(fails, tint, "in the mayor's colour") + var council := Color(str(D.SUPPORT_AGENTS["council"]["color"])) + var others := S.ping_color("ai:council").is_equal_approx(council) + K.check( + fails, others and S.ping_color("event:fire") == S.RED, "agents in theirs, incidents red" + ) + var quiet := ctx.hud.toasts.texts().is_empty() and ctx.hud.toasts.banner_text().is_empty() + K.check(fails, quiet, "an AI ping raises no toast (the feed already says it)") + line = str(feed.call("line_text")) + K.check(fails, not line.contains("deciding"), "the deciding line clears (%s)" % line) + ctx.hud.pings.clear() + var held := { + "day": ctx.sim.day, + "thought": "", + "post": "Patience.", + "actions": [], + "source": "heuristic", + "error": "HTTP 503", + "focus": D.NO_TILE, + } + f.call("_on_mayor_decided", 1, held) + card = feed.call("open_card") + var fb := ( + _badge(card, "Source") == "FALLBACK" and _text(card, "Headline") == "Held off this turn" + ) + K.check(fails, fb, "a failed live call reads FALLBACK") + var district := _text(card, "Where") == str(D.MAYORS[1]["district"]) + var no_hint := not (card.find_child("Route", true, false) as Control).visible + K.check(fails, district and no_hint, "a turn without a place names the district, no G hint") + K.check(fails, ctx.hud.pings.is_empty(), "and pings nothing") + K.check(fails, _text(card, "Detail").contains("Patience."), "the post stands in for no thought") + + +func _check_support(ctx: FpContext, f: FpFeature) -> void: + var feed: Control = f.get("feed") + var day := ctx.sim.day + ctx.hud.pings.clear() + var ruling := { + "enacted": ["curfew"], + "repealed": ["clean_air_act"], + "source": "cache", + "day": day, + "statement": "Qwen and Grok voted no.", + } + f.call("_on_council_ruled", ruling) + var card: Control = feed.call("open_card") + var speaker := _text(card, "Who") == "Council Speaker" and _badge(card, "Model") == "gpt-6-luna" + K.check( + fails, speaker, "the council card names the speaker and model (%s)" % _badge(card, "Model") + ) + var head := _text(card, "Headline") + var head_ok := head == "Passed Youth Curfew · Repealed Clean Air Act" + K.check(fails, head_ok, "what the council did (%s)" % head) + var hall := ctx.sim.kind.find(D.HALL) + var it: Dictionary = (f.get("items") as Array)[-1] + var at_hall := hall < 0 or (it["tile"] as Vector2i) == Vector2i(D.tx(hall), D.ty(hall)) + K.check(fails, at_hall and bool(it["placed"]), "the council sits at City Hall") + var replay := _badge(card, "Source") == "REPLAY" and not _text(card, "Detail").contains("Qwen") + K.check(fails, replay, "a replayed ruling, de-branded") + var pinged := not ctx.hud.pings.is_empty() and str(ctx.hud.pings[-1]["reason"]) == "ai:council" + K.check(fails, pinged, "the council pings City Hall") + ctx.hud.pings.clear() + var pick := { + "trigger": true, + "type": "fire", + "x": 6, + "y": 7, + "reason": "Old wiring downtown.", + "source": "heuristic", + "day": day, + } + f.call("_on_chaos_decided", pick) + card = feed.call("open_card") + head = _text(card, "Headline") + var chaos_ok := head == "Set off a building fire" and _badge(card, "Source") == "OFFLINE" + K.check(fails, chaos_ok, "the Chaos Director's pick, from the offline planner (%s)" % head) + var beacon := bool((f.get("beacons") as Node).call("has_beacon", "chaos")) + K.check(fails, beacon and ctx.hud.pings.is_empty(), "a beacon, but the incident pings the map") + f.call("_on_chaos_decided", {"trigger": false, "narration": "A calm day.", "source": "cache"}) + head = _text(feed.call("open_card"), "Headline") + K.check(fails, head == "Kept the city calm", "a calm pick (%s)" % head) + var order := {"priority": [3, 0, 1, 2, 4], "advice": "Build a plant.", "source": "heuristic"} + f.call("_on_operator_decided", order) + head = _text(feed.call("open_card"), "Headline") + K.check(fails, head == "Power goes first to " + str(D.MAYORS[3]["district"]), "the grid order") + var paper := { + "headline": "Kimi-K3 opens the bridge", "editorial": "About time.", "source": "cache" + } + f.call("_on_news_published", paper) + head = _text(feed.call("open_card"), "Headline") + var gazette := head.begins_with("Front page: ") and not head.contains("Kimi") + K.check(fails, gazette, "the Gazette's front page, de-branded (%s)" % head) + var kept := int(feed.call("card_count")) == 4 and (f.get("items") as Array).size() == 12 + K.check(fails, kept, "the feed keeps four cards and the feature twelve decisions") + + +func _check_branding(f: FpFeature) -> void: + var feed: Control = f.get("feed") + var texts := S.owned_texts(feed) + var re := RegEx.create_from_string(LEAK_RE) + var bad := PackedStringArray() + for t in texts: + if re.search(t) != null: + bad.append(t) + var msg := "no brand or system words in %d feed texts %s" % [texts.size(), str(bad)] + K.check(fails, texts.size() > 10 and bad.is_empty(), msg) + var badges := 0 + for n in feed.find_children("*", "Label", true, false): + if n.has_meta(S.BADGE_META) and not n.has_meta(S.OWNER_META): + badges += 1 + K.check(fails, badges >= 8, "model and source tags are badges (%d)" % badges) + + +func _check_modes(ctx: FpContext, f: FpFeature) -> void: + var feed: Control = f.get("feed") + var tags: Control = f.get("tags") + var full := str(feed.get("mode")) == "full" and _shown(feed) == 4 + K.check(fails, full, "full: four cards") + f._unhandled_input(_key(KEY_J)) + var brief := str(feed.get("mode")) == "brief" and _shown(feed) == 1 + K.check(fails, brief, "J: brief, the newest card only") + f._unhandled_input(_key(KEY_J)) + f._process(DT) + var hidden := str(feed.get("mode")) == "hidden" and _shown(feed) == 0 + var bare := not tags.visible and str(feed.call("line_text")).is_empty() + K.check(fails, hidden and bare, "J again: hidden, the header only") + f._unhandled_input(_key(KEY_J)) + K.check(fails, str(feed.get("mode")) == "full", "J again: full") + ctx.push_modal(&"test") + f._unhandled_input(_key(KEY_J)) + K.check(fails, str(feed.get("mode")) == "full", "J does nothing under a modal") + ctx.pop_modal(&"test") + + +func _check_route(ctx: FpContext, f: FpFeature) -> void: + ctx.hud.clear_waypoint() + var newest: Dictionary = f.call("newest_placed") + K.check(fails, str(newest.get("key", "")) == "chaos", "the newest decision with a place") + f._unhandled_input(_pad(JOY_BUTTON_Y)) + K.check(fails, ctx.hud.waypoint() == null, "the pad's Y does not route (it opens the map)") + ctx.push_modal(&"test") + f._unhandled_input(_key(KEY_G)) + K.check(fails, ctx.hud.waypoint() == null, "G does nothing under a modal") + ctx.pop_modal(&"test") + ctx.hud.toasts.clear() + f._unhandled_input(_key(KEY_G)) + var wp: Variant = ctx.hud.waypoint() + var t: Vector2i = newest["tile"] + var near := ( + wp != null and Vector2((wp as Vector3).x - t.x, (wp as Vector3).z - t.y).length() < 1.6 + ) + K.check(fails, near, "G routes the GPS there (%s)" % str(wp)) + var said := ctx.hud.toasts.texts() + var last := said[said.size() - 1] if not said.is_empty() else "" + K.check( + fails, last.begins_with("Route set: Set off a building fire"), "and says so (%s)" % last + ) + ctx.hud.clear_waypoint() + + +func _check_tags(ctx: FpContext, f: FpFeature) -> void: + var beacons: Node3D = f.get("beacons") + var tags: Control = f.get("tags") + var key := "mayor:2" + var at: Vector3 = beacons.call("position_of", key) + K.check(fails, _stand_facing(ctx, at), "fixture: a road a few tiles from the beacon") + f._process(DT) + var shown: Array = tags.call("shown") + K.check( + fails, shown.has(key), "a tag names the decision over a beacon in view (%s)" % str(shown) + ) + var far := {"key": "far", "pos": at, "who": "x", "what": "y", "color": Color.WHITE} + tags.call("show_tags", [far], ctx.player.camera, at + Vector3(0.0, 0.0, 40.0)) + K.check(fails, (tags.call("shown") as Array).is_empty(), "no tag from 40 tiles away") + var cam: Camera3D = ctx.player.camera + var me := ctx.player.position + var ahead := -cam.global_basis.z + ahead = Vector3(ahead.x, 0.0, ahead.z).normalized() + var close := { + "key": "close", "pos": me + ahead * 0.6, "who": "x", "what": "y", "color": Color.WHITE + } + tags.call("show_tags", [close], cam, me) + K.check( + fails, + (tags.call("shown") as Array) == ["close"], + "a beacon 12 m ahead keeps its tag on screen, low enough to read" + ) + var everywhere: Array[Rect2] = [Rect2(-1.0e5, -1.0e5, 2.0e5, 2.0e5)] + tags.call("show_tags", [close], cam, me, everywhere) + K.check( + fails, + (tags.call("shown") as Array).is_empty(), + "a tag that would cover a HUD area is left out, not pushed to the edge" + ) + var clear: Array = f.call("_keep_clear") + var feed_rect: Rect2 = (f.get("feed") as Control).call("column_rect") + K.check( + fails, clear.has(feed_rect) and clear.size() == 4, "tags keep clear of the feed and HUD" + ) + + +func _check_cover(ctx: FpContext, f: FpFeature) -> void: + var feed: Control = f.get("feed") + var beacons: Node3D = f.get("beacons") + ctx.push_modal(&"pause") + K.check(fails, not feed.visible, "the pause menu hides the feed at once (it stops _process)") + f._process(DT) + K.check(fails, not feed.visible, "a menu or card hides the feed") + ctx.pop_modal(&"pause") + K.check(fails, feed.visible, "and closing it brings the feed back") + ctx.push_modal(&"chat") + f._process(DT) + var tags: Control = f.get("tags") + K.check(fails, feed.visible, "the chat input leaves it up") + K.check(fails, not tags.visible, "the open chat drawer hides the beacon tags") + ctx.pop_modal(&"chat") + ctx.player.set_photo_mode(true) + f._process(DT) + K.check(fails, not beacons.visible, "photo mode hides the beacons") + ctx.player.set_photo_mode(false) + f._process(DT) + K.check(fails, beacons.visible and feed.visible, "and they come back") + + +func _check_live_state(ctx: FpContext, f: FpFeature) -> void: + var feed: Control = f.get("feed") + var c := ctx.client + f.call("_refresh") + K.check(fails, str(feed.call("state")) == "offline", "offline play says Offline") + c.live_enabled = true + f.call("_refresh") + K.check(fails, str(feed.call("state")) == "live", "live calls say Live") + for _k in range(6): + c.live_outcomes.append(false) + f.call("_refresh") + var down := str(feed.call("state")) + K.check(fails, down == "down", "failing live calls say Reconnecting (%s)" % down) + c.live_outcomes.clear() + c.live_enabled = false + f.call("_refresh") + + +func _check_save(ctx: FpContext, f: FpFeature) -> void: + var feed: Control = f.get("feed") + var beacons: Node3D = f.get("beacons") + feed.call("set_mode", "brief") + var saved: Dictionary = JSON.parse_string(JSON.stringify(f.to_dict())) + var n := (f.get("items") as Array).size() + var heads: PackedStringArray = feed.call("headlines") + ((saved["items"] as Array)[-1] as Dictionary)["detail"] = "Grok-4.5 was here" + ctx.hud.pings.clear() + f.from_dict(saved) + var items: Array = f.get("items") + var back := items.size() == n and str(feed.get("mode")) == "brief" + K.check(fails, back, "a load restores %d decisions and the feed mode" % n) + K.check(fails, feed.call("headlines") == heads, "the same cards come back (%s)" % str(heads)) + var lit := ( + bool(beacons.call("has_beacon", "mayor:2")) and bool(beacons.call("has_beacon", "chaos")) + ) + K.check(fails, lit and ctx.hud.pings.is_empty(), "and the beacons, without new pings") + K.check( + fails, not str(items[-1]["detail"]).contains("Grok"), "saved text is de-branded on load" + ) + var ago := _text(feed.call("open_card"), "Ago") + K.check(fails, ago.begins_with("Day "), "restored cards date by day (%s)" % ago) + var tile: Vector2i = (f.call("newest_placed") as Dictionary).get("tile", D.NO_TILE) + K.check(fails, tile == Vector2i(6, 7), "places survive the save (%s)" % str(tile)) + feed.call("set_mode", "full") + var brain := ctx.brain + brain.turns.start(ctx.sim.day, true) + brain.turns.take_due(1.0, true) + var due := brain.turns.due.duplicate() + brain.from_dict(JSON.parse_string(JSON.stringify(brain.to_dict()))) + var kept := brain.turns.due == due and brain.turns.day == ctx.sim.day + K.check(fails, kept, "the brain's save keeps the waiting turns (%s)" % str(brain.turns.due)) + brain.turns.clear() + var mt := MT.new() + mt.from_dict({"due": [1, 1, 7, -2, "x", 3.0], "day": 4}) + var clean := mt.due == [1, 3] and mt.day == 4 + K.check(fails, clean, "a saved queue drops unknown and repeated mayors (%s)" % str(mt.due)) + f.from_dict({}) + var empty := (f.get("items") as Array).is_empty() and int(feed.call("card_count")) == 0 + K.check(fails, empty and (beacons.call("keys") as Array).is_empty(), "an empty save clears it") + ctx.hud.toasts.clear() + K.check(fails, f.call("route_to_newest") == false, "G without a decision on the map") + var told := ctx.hud.toasts.texts() + K.check( + fails, told.size() == 1 and told[0].begins_with("No AI decision"), "says so (%s)" % told + ) + K.check(fails, f.to_dict().is_empty(), "and there is nothing to save") + + +func _check_teardown(ctx: FpContext, f: FpFeature) -> void: + f.teardown() + var gone := f.get("feed") == null and f.get("beacons") == null and f.get("tags") == null + K.check( + fails, gone and not ctx.brain.stagger_mayors, "teardown removes it and ends the stagger" + ) + var linked := ctx.brain.mayor_decided.is_connected(Callable(f, "_on_mayor_decided")) + K.check(fails, not linked, "and lets go of the brain's signals") + + +# ------------------------------------------------------------------ helpers + + +## Steps the sim to the start of the next day. +func _next_day(ctx: FpContext) -> void: + var d := ctx.sim.day + var guard := 0 + while ctx.sim.day == d and guard < 4 * D.GROWTH_TICKS_PER_DAY: + ctx.sim.step_tick() + guard += 1 + + +## A built home's tile in the grown city. +func _lot_tile(ctx: FpContext) -> Vector2i: + for i: int in ctx.sim.active: + if ctx.sim.kind[i] == D.ZONE_R: + return Vector2i(D.tx(i), D.ty(i)) + return Vector2i(4, 7) + + +## Puts the hero on a road 4 to 12 tiles from at, facing it. False when there is no such road. +func _stand_facing(ctx: FpContext, at: Vector3) -> bool: + var best := -1 + var best_gap := INF + for i: int in range(ctx.sim.kind.size()): + if ctx.sim.kind[i] != D.ROAD: + continue + var d := Vector2(D.tx(i) - at.x, D.ty(i) - at.z).length() + if d >= 4.0 and d <= 12.0 and absf(d - 6.0) < best_gap: + best = i + best_gap = absf(d - 6.0) + if best < 0: + return false + var pos := Vector3(D.tx(best), 0.0, D.ty(best)) + var dir := Vector2(at.x - pos.x, at.z - pos.z).normalized() + ctx.player.teleport(pos, rad_to_deg(atan2(-dir.x, -dir.y))) + for _k in range(40): + ctx.player.update_view(DT) + return true + + +## Cards the feed shows now. +func _shown(feed: Control) -> int: + return (feed.call("headlines") as PackedStringArray).size() + + +func _text(root: Node, n: String) -> String: + if root == null: + return "" + var l := root.find_child(n, true, false) as Label + return l.text if l != null else "" + + +## The text of the badge named n under root, "" when it is missing or not a badge. +func _badge(root: Node, n: String) -> String: + if root == null: + return "" + var p := root.find_child(n, true, false) + if p == null or p.get_child_count() == 0: + return "" + var l := p.get_child(0) as Label + return l.text if l != null and l.has_meta(S.BADGE_META) else "" + + +func _key(k: Key) -> InputEventKey: + var e := InputEventKey.new() + e.physical_keycode = k + e.pressed = true + return e + + +func _pad(button: JoyButton) -> InputEventJoypadButton: + var e := InputEventJoypadButton.new() + e.button_index = button + e.pressed = true + return e diff --git a/tools/tests/fp_activity_test.gd.uid b/tools/tests/fp_activity_test.gd.uid new file mode 100644 index 00000000..d2745499 --- /dev/null +++ b/tools/tests/fp_activity_test.gd.uid @@ -0,0 +1 @@ +uid://c2it23uvgxwdi diff --git a/tools/tests/fp_brainspread_test.gd b/tools/tests/fp_brainspread_test.gd index 2f0d6b3d..515f790a 100644 --- a/tools/tests/fp_brainspread_test.gd +++ b/tools/tests/fp_brainspread_test.gd @@ -3,10 +3,13 @@ extends SceneTree ## sliced day job only the first mayor runs in the day_started emission and the other pieces follow ## one per frame through sim.run_in_phase, while the synchronous path (step_tick) still runs the ## whole round inline. Both paths end in the same city and brain state, every call of a round -## carries that round's trace id, and a save mid-round drains the round first. Headless, offline. +## carries that round's trace id, and a save mid-round drains the round first. Staggered turns +## (street mode) land without forcing a synchronous finish. Headless, offline. ## Prints FP_BRAINSPREAD_TEST: PASS|FAIL. const TICKS := 20 +## Frames per game day in _street_frames. +const DAY_FRAMES := 1600 const ROLES := ["mayor", "citizens", "news", "chaos", "operator", "council"] var fails: Array[String] = [] @@ -35,6 +38,8 @@ func _init() -> void: _free_world(a) _free_world(b) await process_frame + await _check_staggered() + await process_frame print("FP_BRAINSPREAD_TEST: " + ("PASS" if fails.is_empty() else "FAIL " + str(fails))) quit(0 if fails.is_empty() else 1) @@ -219,6 +224,59 @@ func _check_save_mid_round(w: Dictionary) -> void: sim.to_dict() +## Street mode (CityBrain.stagger_mayors) spreads the mayors' turns over the day. They start from +## the brain's _process, mid-tick or between ticks, so their actions must not force the tick in +## flight, or the spread work it waits for, to finish in a single frame. +func _check_staggered() -> void: + var w := _world(true) + var sim: CitySim = w["sim"] + var brain: CityBrain = w["brain"] + brain.stagger_mayors = true + sim.slice_budget_ms = 0.0 + sim.set_speed(1) + # Measured over the first whole staggered day, from its dawn to the next. + var day := sim.day + 1 + var seen := {"queued": -1, "forced": 0, "ticks": []} + sim.day_started.connect( + func(d: int) -> void: + var n := int(sim.slice_stats().get("sync_finishes", 0)) + if d == day: + seen["queued"] = brain.turns.due.size() + seen["forced"] = n + elif d == day + 1: + seen["forced"] = n - int(seen["forced"]) + ) + brain.mayor_decided.connect( + func(_mi: int, e: Dictionary) -> void: + if int(e.get("day", -1)) == day: + (seen["ticks"] as Array).append(sim.tick_in_day) + ) + await _street_frames(sim, func() -> bool: return brain.turns.day > day) + var ticks: Array = seen["ticks"] + var spread := {} + for t: int in ticks: + spread[t] = true + check( + int(seen["queued"]) == 5 and ticks.size() == 5 and spread.size() >= 3, + "the five staggered turns land through the day (ticks %s)" % str(ticks) + ) + check( + int(seen["forced"]) == 0, "and never force a synchronous finish (%d)" % int(seen["forced"]) + ) + _free_world(w) + + +## Street-mode frames until done() holds: the sim's own sliced clock at a fixed DAY_FRAMES frames +## per day, one job step per frame (a zero budget), so a tick spans many frames and the brain's +## _process sees turns come due both mid-tick and between ticks, the same on every machine. +func _street_frames(sim: CitySim, done: Callable) -> void: + var frame := 0 + while not bool(done.call()) and frame < 20000: + frame += 1 + sim._process(sim.day_seconds / float(DAY_FRAMES)) + await process_frame + + func _node(path: String) -> Node: var n: Node = (load(path) as GDScript).new() get_root().add_child(n) diff --git a/tools/tests/fp_core_test.gd b/tools/tests/fp_core_test.gd index 26e7ce9a..5f04d912 100644 --- a/tools/tests/fp_core_test.gd +++ b/tools/tests/fp_core_test.gd @@ -79,7 +79,7 @@ func _check_registry(ctx: FpContext) -> void: ids[str(e["id"])] = true if f is Node: (f as Node).free() - K.check(fails, ok and ids.size() == 12, "all 12 registry entries load as FpFeatures with matching unique ids") + K.check(fails, ok and ids.size() == 13, "all 13 registry entries load as FpFeatures with matching unique ids") var feat := K.make_feature(ctx, "traffic") K.check(fails, feat != null and feat.is_inside_tree() and ctx.feature("traffic") == feat, "make_feature puts the feature in the tree and ctx.features") var st: Dictionary = feat.self_test() if feat != null else {}