From 1a6f522dd35434a9003b4161b183bea057873ff7 Mon Sep 17 00:00:00 2001 From: Kunal Siyag Date: Thu, 22 Oct 2026 10:14:52 +0530 Subject: [PATCH] feat(fp): report for the Gazette: chase AI leads, quote people, file stories, rank the mayors The player can now work as the GridFall Gazette's stringer. Every AI decision with a place becomes a lead: a mayor's order, a council ruling or a Chaos Director strike. K takes the newest on assignment and sets the GPS to the scene. A reply from someone there is the quote, and a photo-mode shot with the scene in the viewfinder's marks is a sharp photo. K files the story: the Gazette runs it in the chat and it earns points towards the press ranks, with bonuses for a scoop, a local voice and a sharp shot. L opens the mayors' league: the five mayors ranked on people, jobs, approval and funds, each with the model it runs on, plus the press card, open leads and the latest stories. New integration feature `reporter` (flag on by default) with the desk, card, viewfinder and league. GameBus gains talk_replied (emitted by chat for face-to-face replies and "Ask someone here") and story_filed (chat runs it as a Gazette line). Inputs press_story (K) and press_league (L). Shared input-event helpers move from fp_actions_test to fp_kit. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- README.md | 2 +- data/feature_flags.json | 1 + docs/api/index.md | 8 + docs/api/scripts.fp.md | 2 + docs/api/scripts.fp.reporter.md | 174 ++++ docs/fp/ARCHITECTURE.md | 1 + docs/fp/S0-foundation.md | 10 + project.godot | 10 + scripts/fp/chat/chat_feature.gd | 13 + scripts/fp/chat/talk_dialog.gd | 4 + scripts/fp/fp_features.gd | 4 + scripts/fp/game_bus.gd | 8 + scripts/fp/reporter/reporter_card.gd | 177 ++++ scripts/fp/reporter/reporter_card.gd.uid | 1 + scripts/fp/reporter/reporter_desk.gd | 231 +++++ scripts/fp/reporter/reporter_desk.gd.uid | 1 + scripts/fp/reporter/reporter_feature.gd | 729 ++++++++++++++ scripts/fp/reporter/reporter_feature.gd.uid | 1 + scripts/fp/reporter/reporter_league.gd | 247 +++++ scripts/fp/reporter/reporter_league.gd.uid | 1 + scripts/fp/reporter/reporter_viewfinder.gd | 115 +++ .../fp/reporter/reporter_viewfinder.gd.uid | 1 + scripts/fp/ui/pause_menu.gd | 1 + tools/tests/fp_actions_test.gd | 38 +- tools/tests/fp_core_test.gd | 2 +- tools/tests/fp_kit.gd | 25 + tools/tests/fp_reporter_test.gd | 944 ++++++++++++++++++ tools/tests/fp_reporter_test.gd.uid | 1 + 28 files changed, 2720 insertions(+), 32 deletions(-) create mode 100644 docs/api/scripts.fp.reporter.md create mode 100644 scripts/fp/reporter/reporter_card.gd create mode 100644 scripts/fp/reporter/reporter_card.gd.uid create mode 100644 scripts/fp/reporter/reporter_desk.gd create mode 100644 scripts/fp/reporter/reporter_desk.gd.uid create mode 100644 scripts/fp/reporter/reporter_feature.gd create mode 100644 scripts/fp/reporter/reporter_feature.gd.uid create mode 100644 scripts/fp/reporter/reporter_league.gd create mode 100644 scripts/fp/reporter/reporter_league.gd.uid create mode 100644 scripts/fp/reporter/reporter_viewfinder.gd create mode 100644 scripts/fp/reporter/reporter_viewfinder.gd.uid create mode 100644 tools/tests/fp_reporter_test.gd create mode 100644 tools/tests/fp_reporter_test.gd.uid diff --git a/README.md b/README.md index 74d1bdcc..215b02c3 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,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: your character walks round to the driver's door, opens it, climbs in and shuts it (`F` on the way turns back). W/S throttle and brake or reverse, A/D steer, Space handbrake, V switches chase and driver view, and `F` again, once the car slows down, brakes to a stop and climbs out the same way. The car is solid: it bounces off walls, scrapes along them at a glance, shoves the cars it hits (they slide off, then pull back into their lane) and sends bins, benches, planters and other light street props flying. People it runs into stagger back, or go down when it is going fast. On foot, left click shoves whoever stands in front of you. Hold right click to put your guard up (fists up, the camera in closer), and left click then throws a jab and a cross in turn. A light blow makes people reel and step back. A hard one, the third punch in a row or a fast car knocks them down: they lie a while, get up, then shout at you, run off or walk back, and the people nearby who saw it run off or shout too. A resident who was hit remembers it and their mood drops (a shove least, a car most), and the ones who saw it remember it too. Floor someone or keep punching and the police drive out from the nearest station; someone a car knocks down gets an ambulance. The one who was hit and a witness say so in the chat, a crew called to the scene posts a City Alert card, and you hear each blow, swing and fall. `1`-`5` wave, nod, shake your head, shout and dance, and the people around answer. `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, and one of your two building crews builds it in real time (see Staged construction). The phone says how many crews are free and how long the order takes, a card under the top bar counts down your orders, and `E` on a worker gets a word about the job. 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. +- **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: your character walks round to the driver's door, opens it, climbs in and shuts it (`F` on the way turns back). W/S throttle and brake or reverse, A/D steer, Space handbrake, V switches chase and driver view, and `F` again, once the car slows down, brakes to a stop and climbs out the same way. The car is solid: it bounces off walls, scrapes along them at a glance, shoves the cars it hits (they slide off, then pull back into their lane) and sends bins, benches, planters and other light street props flying. People it runs into stagger back, or go down when it is going fast. On foot, left click shoves whoever stands in front of you. Hold right click to put your guard up (fists up, the camera in closer), and left click then throws a jab and a cross in turn. A light blow makes people reel and step back. A hard one, the third punch in a row or a fast car knocks them down: they lie a while, get up, then shout at you, run off or walk back, and the people nearby who saw it run off or shout too. A resident who was hit remembers it and their mood drops (a shove least, a car most), and the ones who saw it remember it too. Floor someone or keep punching and the police drive out from the nearest station; someone a car knocks down gets an ambulance. The one who was hit and a witness say so in the chat, a crew called to the scene posts a City Alert card, and you hear each blow, swing and fall. `1`-`5` wave, nod, shake your head, shout and dance, and the people around answer. `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, and one of your two building crews builds it in real time (see Staged construction). The phone says how many crews are free and how long the order takes, a card under the top bar counts down your orders, and `E` on a worker gets a word about the job. 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. Each of those decisions is also a lead for the Gazette, and you are its stringer: `K` takes the newest on assignment, and a card shows what happened, where and how far, and what the story still needs, while the GPS leads you to the scene. There, a reply from someone you talk to (or the answer when you ask someone here) is your quote. In photo mode (`P`), a click with the scene inside the viewfinder's marks is a press photo. `K` then files the story: the Gazette runs it in the chat, the person quoted remembers it, and it earns points towards the press ranks, with bonuses for a scoop (filed within a third of a day), a local voice and a sharp photo. A lead goes cold after a day. `L` opens the mayors' league: the five mayors ranked on people, jobs, approval and funds, each with the model they run on, your press card, the open leads (chase any of them) and your latest stories. The orbit view (`GRIDFALL_VIEW=orbit`): diff --git a/data/feature_flags.json b/data/feature_flags.json index bf365aff..e4d41e8e 100644 --- a/data/feature_flags.json +++ b/data/feature_flags.json @@ -18,6 +18,7 @@ "crews": {"default": true, "description": "First-person feature: the player's two building crews. Phone orders are built in real time (2-5 minutes for a building) by crews you can watch on site, with a board and a HUD card counting down; later orders queue for a free crew."}, "props": {"default": true, "description": "First-person feature: street props the player's car knocks over. Bins, benches, planters, mailboxes, bike racks and parking meters hit hard enough tumble off, come to rest and can be hit again; they stand up again out of sight. Slower hits and lamps, trees, bollards and hydrants hold like walls."}, "actions": {"default": true, "description": "First-person feature: the hero's body actions. Left mouse (right trigger) shoves whoever stands in front; with the guard up (right mouse or left trigger held) it throws a jab and a cross in turn. People reel or go down, the street reacts and the city hears of it. Keys 1-5 wave, nod, shake the head, shout and dance, and the people around answer."}, + "reporter": {"default": true, "description": "First-person feature: the player as the Gazette's stringer. Every AI decision with a place is a lead: K takes one on assignment and sets a waypoint to the scene, a quote from someone there (a talk reply or the Ask someone here answer) and an optional press photo (photo mode, scene in the marks) make the story, and K files it into the chat for points and press ranks. L opens the mayors' league: the five mayors ranked, the press card, the open leads and the latest stories."}, "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 61671efe..9bd23c99 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -136,6 +136,14 @@ Generated from the `##` doc comments in `scripts/`. Regenerate with `python3 too - [quality.gd](scripts.fp.render.md#scriptsfprenderqualitygd): First-person quality presets (slice G2): the knobs each preset sets, and the static helpers every slice reads without a feature lookup (vis_mult, max_street_lights, camera_far) - [render_opt_feature.gd](scripts.fp.render.md#scriptsfprenderrender_opt_featuregd): Render performance (slice G2): applies the quality preset (FSR2 scaling, shadow atlas and filter, SSAO and SSIL quality, mesh LOD threshold, camera far plane) and restores every knob on teardown; on foot it ranges every tile holder's parts (hlod.gd), draws far buildings as merged chunk impostors and culls with per-chunk occluders (occluders.gd) +## [scripts/fp/reporter/](scripts.fp.reporter.md) + +- [reporter_card.gd](scripts.fp.reporter.md#scriptsfpreporterreporter_cardgd): The Gazette card on the HUD (feature "reporter"), bottom right above the district caption: the newest lead and the key to take it, or the story on assignment: what happened, where and how far, the scoop clock, the quote so far and what the story still needs (the scene, a quote, a photo), with the key to file it +- [reporter_desk.gd](scripts.fp.reporter.md#scriptsfpreporterreporter_deskgd): The Gazette's desk (feature "reporter"): which AI decisions make a lead and how they read ("Viktor Han ordered a Police Station"), what a filed story says and earns, the press ranks, what a photo must frame, and the mayors' league (people, jobs, approval and funds, worth 25 points each) +- [reporter_feature.gd](scripts.fp.reporter.md#scriptsfpreporterreporter_featuregd): The Gazette's stringer (integration feature "reporter"; docs/fp/ARCHITECTURE.md, Reporter) +- [reporter_league.gd](scripts.fp.reporter.md#scriptsfpreporterreporter_leaguegd): The mayors' league (feature "reporter", L): the five AI mayors ranked on people, jobs, approval and funds (25 points each, ReporterDesk.standings), each with the badge of the model they run on, then the player's press card (rank, points, stories filed), the story on assignment, the open leads with a button to chase one, and the latest stories +- [reporter_viewfinder.gd](scripts.fp.reporter.md#scriptsfpreporterreporter_viewfindergd): The press camera's viewfinder (feature "reporter"), over the view in photo mode while a story is on assignment: corner marks round the middle of the view a press photo must hold the scene in (ReporterDesk.FRAME), green while it does, the story above the marks and what to do below them, the last shot's result under that, and a white flash for the shutter + ## [scripts/fp/sim/](scripts.fp.sim.md) - [sim_perf_feature.gd](scripts.fp.sim.md#scriptsfpsimsim_perf_featuregd): Time-sliced sim ticks for first person (slice F) diff --git a/docs/api/scripts.fp.md b/docs/api/scripts.fp.md index be781cb1..0e43fe9c 100644 --- a/docs/api/scripts.fp.md +++ b/docs/api/scripts.fp.md @@ -247,6 +247,8 @@ Signals that cross first-person slices. Emitters and listeners are listed in doc - `prop_knocked(at: Vector3, kind: String, speed: float)`: A street prop was knocked over at a point (its StreetDressing kind: "bin", "bench" ...) and flies off at speed (u/s). Emitted by the props feature. - `person_hit(at: Vector3, cause: String, info: Dictionary)`: Someone on foot was hit at a point (their feet): cause "shove" or "punch" (the hero) or "car" (the car the player drives); info is traffic_feature.gd hit_walker()'s {"walker", "resident_id", "name", "down", "street", "strength", "witnesses", "crew"}. Emitted by traffic. - `hero_action(action: String, at: Vector3)`: The hero swung or gestured: action "jab", "cross", "shove" or an emote ("wave", "yes", "no", "shout", "dance"), at their feet. Emitted by the actions feature. +- `talk_replied(resident_id: String, speaker: String, reply: String)`: Someone answered the hero: a resident face to face (their id and name), or whoever answered "Ask someone here" (resident_id "" and their role, "Shopkeeper"). reply is filtered for display. Emitted by chat. +- `story_filed(story: Dictionary)`: The player filed a story for the Gazette: reporter_feature.gd file_story()'s {"agent", "headline", "place", "quote", "by", "tile", "district", "day", "scoop", "local", "sharp", "points", "text"}. Emitted by the reporter feature; chat runs "text" as a Gazette line. ## `scripts/fp/obb2d.gd` diff --git a/docs/api/scripts.fp.reporter.md b/docs/api/scripts.fp.reporter.md new file mode 100644 index 00000000..4b951509 --- /dev/null +++ b/docs/api/scripts.fp.reporter.md @@ -0,0 +1,174 @@ + + +# `scripts/fp/reporter/` + +[Index](index.md) + +## `scripts/fp/reporter/reporter_card.gd` + +extends `Control` + +The Gazette card on the HUD (feature "reporter"), bottom right above the district caption: the newest lead and the key to take it, or the story on assignment: what happened, where and how far, the scoop clock, the quote so far and what the story still needs (the scene, a quote, a photo), with the key to file it. The feature fills it through show_state(); nothing here reads the sim. + +### Constants + +- `S = preload("res://scripts/fp/ui/hud_style.gd")` +- `WIDTH = 320.0` +- `INNER = WIDTH - 27.0`: Text width inside the card: WIDTH less the 12 px side margins and the 3 px stripe. +- `SAFE = 24.0` +- `FROM_BOTTOM = SAFE + 104.0`: The bottom edge, clear of the district caption (90 px tall over the bottom safe margin). +- `BG = Color(0.03, 0.04, 0.07, 0.78)` +- `TICK = 9.0` + +### Functions + +- `func setup() -> void` +- `func show_state(st: Dictionary) -> void`: st: {"kicker" ("New lead", "On assignment", "Ready to file"), "rank" ("Stringer · 120"), "color" (the deciding agent's), "headline", "where" ("214 Maple Street · 240 m · scoop 4:10"), "quote" ("" for none), "steps" ([text, done] pairs; none for a lead), "hint" (what K does now; "" hides the key)}. +- `func card_text() -> String`: Everything the card says now, " · " between the parts (the step rows read "[x] At the scene"). + +## `scripts/fp/reporter/reporter_desk.gd` + +extends `RefCounted` + +The Gazette's desk (feature "reporter"): which AI decisions make a lead and how they read ("Viktor Han ordered a Police Station"), what a filed story says and earns, the press ranks, what a photo must frame, and the mayors' league (people, jobs, approval and funds, worth 25 points each). No nodes. + +### Constants + +- `D = preload("res://scripts/sim/sim_defs.gd")` +- `FILED = 100`: Points for a filed story, and the bonuses on top: a scoop (filed fast), a local voice (the quote comes from someone who lives in the deciding mayor's district) and a sharp shot (a photo that frames the scene). +- `SCOOP = 50` +- `LOCAL = 25` +- `SHARP = 25` +- `RANKS = [ [0, "Stringer"], [300, "Reporter"], [1000, "Senior reporter"], [2500, "Star reporter"] ]`: Press ranks, [points needed, name], lowest first. +- `ZONED = {"R": "zoned new homes", "C": "zoned new shops", "I": "zoned new industry"}` +- `VAGUE = "made a decision here"` +- `QUOTE_CHARS = 140`: Longest quote kept, in characters. +- `CATEGORY = 25.0`: A league category is worth this many points to the best mayor in it (approval: a 10 out of 10). +- `FRAME = 0.65`: A shot frames its subject when the subject sits inside this central part of the view (a fraction of its width and of its height). + +### Functions + +- `static func mayor_lead(mi: int, entry: Dictionary) -> Dictionary`: The lead in a mayor's turn: what they did at its focus tile, the first order carried out that has a place. {} when the turn has none. A lead is {"agent" ("0".."4", "council" or "chaos"), "headline", "tile": Vector2i, "district" (the deciding mayor; -1 for the others)}. +- `static func council_lead(r: Dictionary, hall: Vector2i) -> Dictionary`: The lead in a council ruling that passed or repealed an ordinance, at City Hall (hall); {} when the council changed nothing. +- `static func chaos_lead(d: Dictionary) -> Dictionary`: The lead in a Chaos Director pick that struck somewhere ("Building Fire reported"); {} for a calm day or a pick without a place. +- `static func deed(a: Dictionary) -> String`: What one order did, as the Gazette puts it ("ordered a Police Station", "zoned new homes"). +- `static func color_of(agent: String) -> Color`: The agent's colour (a mayor's or a support agent's). +- `static func clip(t: String) -> String`: A reply as a quote: de-branded, on one line, at most QUOTE_CHARS (cut at a word, with "…"). +- `static func passerby(speaker: String) -> String`: The byline of a passer-by who answered "Ask someone here": a role reads "a shopkeeper" or "an officer", a name ("Mara Ellis") stays as it is, and nobody reads "someone nearby". +- `static func story_text(s: Dictionary) -> String`: The story as the Gazette runs it in the chat: "From 214 Maple Street: Viktor Han ordered a Police Station. “About time,” says Ana Ruiz." +- `static func points_for(scoop: bool, local: bool, sharp: bool) -> int`: Points for a story: FILED, and SCOOP, LOCAL and SHARP for each bonus it earned. +- `static func bonus_words(s: Dictionary) -> String`: The bonuses a story earned, in words ("scoop, local voice"); "" for none. +- `static func rank_of(points: int) -> Dictionary`: The press rank for points: {"name", "next" (the points the next rank needs, -1 at the top)}. +- `static func framed(p: Vector2, view: Vector2) -> bool`: Whether screen point p (a camera's unproject_position) lies in the central FRAME of a view of this size. +- `static func standings(sim: CitySim, people: Residents) -> Array[Dictionary]`: The league table, best first: a row per mayor {"mi", "who", "model", "style", "district", "color", "pop", "jobs", "approval" (0-10: what the district's residents think of their mayor, else its happiness), "funds", "orders" (carried out), "refused", "score" (0-100)}. People, jobs and funds score against the best mayor, approval out of 10. Ties go to the bigger district. + +## `scripts/fp/reporter/reporter_feature.gd` + +extends `FpFeature` + +The Gazette's stringer (integration feature "reporter"; docs/fp/ARCHITECTURE.md, Reporter). An AI decision with a place is a lead (ReporterDesk): a mayor's order, a council ruling, a Chaos Director strike. K takes the newest on assignment and sets a waypoint to the scene. There the story needs a quote: a reply someone gives the hero face to face, or the answer to "Ask someone here" (bus.talk_replied). It may also carry a photo: in photo mode (P) a click with the scene inside the viewfinder's marks is a sharp shot. K files it: the Gazette runs it in the chat (bus.story_filed) and it earns points towards the press ranks, more for a scoop (filed within a third of a day), a local voice (someone who lives in the district) and a sharp shot. L opens the mayors' league: the five mayors ranked, the press card, the open leads (chase another) and the latest stories. A lead goes cold after a day. + +### Constants + +- `D = preload("res://scripts/sim/sim_defs.gd")` +- `S = preload("res://scripts/fp/ui/hud_style.gd")` +- `DESK = preload("res://scripts/fp/reporter/reporter_desk.gd")` +- `CARD = preload("res://scripts/fp/reporter/reporter_card.gd")` +- `FINDER = preload("res://scripts/fp/reporter/reporter_viewfinder.gd")` +- `LEAGUE = preload("res://scripts/fp/reporter/reporter_league.gd")` +- `MODAL = &"league"` +- `INPUTS = ["press_story", "press_league"]` +- `MAX_LEADS = 4` +- `KEEP_STORIES = 5` +- `LEAD_DAYS = 1.0`: A lead goes cold LEAD_DAYS after it happened; a story filed within SCOOP_DAYS is a scoop (sim days). +- `SCOOP_DAYS = 1.0 / 3.0` +- `SCENE = 3.0`: The scene: within SCENE tiles of the lead's tile. A press photo holds a point SUBJECT_Y above the tile's ground, from within PHOTO_RANGE (u). +- `SUBJECT_Y = 0.15` +- `PHOTO_RANGE = 4.0` +- `QUOTE_MIN = 12`: A reply shorter than this is no quote ("Hm."). +- `REFRESH = 0.5` +- `HALL_AT = Vector2i(15, 14)`: Where a council ruling happens when the city has no City Hall. +- `NO_POINT = Vector3(NAN, NAN, NAN)` +- `PHOTO_FRAMES = 8`: Photo tour: frames to wait for the tour's teleport before staging, and the staged answer. +- `PHOTO_SPEAKER = "Shopkeeper"` +- `PHOTO_QUOTE = "Nobody asked us first, but honestly, it's about time."` + +### Properties + +- `leads: Array[Dictionary]`: Open leads, newest first: ReporterDesk leads with "at" (sim days) and "place". +- `story: Dictionary`: The lead on assignment with its "quote", "by", "quote_id" (the resident's id, "" for a passer-by), "local", "sharp", "seen" (the hero reached the scene) and "wp" (the waypoint set for it); {} for none. +- `stories: Array[Dictionary]`: The latest filed stories, newest first: {"text", "points", "bonus", "day"}. + +### Functions + +- `func feature_id() -> String` +- `func setup(p_ctx: FpContext) -> void` +- `func on_world_rebuilt() -> void`: Boot, load or New City: the old city's leads and story are gone. +- `func to_dict() -> Dictionary` +- `func from_dict(d: Dictionary) -> void` +- `func teardown() -> void` +- `func self_test() -> Dictionary` +- `func take_lead(k: int = 0) -> bool`: Takes open lead k (0: the newest) on assignment, dropping any story already on it, and sets a waypoint to its scene. False when there is no such lead. +- `func press_story() -> void`: K: takes the newest lead, or files the story on assignment once it has its quote (else says what it still needs). +- `func file_story() -> Dictionary`: Files the story on assignment once it has its quote: points towards the press ranks, the Gazette's chat line (bus.story_filed) and a memory for the resident quoted. Returns the story as filed (bus.story_filed's dict), {} when there is nothing to file. +- `func at_scene() -> bool`: True while the hero is at the scene of the story on assignment (in photo mode: stood there when the camera came up). +- `func scene_framed() -> bool`: True when the camera holds the scene of the story on assignment inside the viewfinder's marks (ReporterDesk.framed), from within PHOTO_RANGE. +- `func snap() -> bool`: The shutter (a click in photo mode on an assignment): a sharp shot when the hero stands at the scene and the camera holds it inside the marks. True for a sharp shot. +- `func open_league() -> bool`: Opens the mayors' league (L). False when it may not open now: no HUD, a modal open, paused, in photo mode or talking. +- `func close_league() -> void` +- `func photo_shots() -> Array`: Three shots of a lead: its scene with the card on assignment, the press camera there with a sharp shot taken, and the league over the street at the spawn once the story is filed. + +## `scripts/fp/reporter/reporter_league.gd` + +extends `Control` + +The mayors' league (feature "reporter", L): the five AI mayors ranked on people, jobs, approval and funds (25 points each, ReporterDesk.standings), each with the badge of the model they run on, then the player's press card (rank, points, stories filed), the story on assignment, the open leads with a button to chase one, and the latest stories. The feature fills it through show_league(); nothing here reads the sim. + +### Signals + +- `chase_requested(lead: int)`: The Chase button of open lead k (0: the newest) was pressed. + +### Constants + +- `S = preload("res://scripts/fp/ui/hud_style.gd")` +- `WIDTH = 860.0` +- `COLS = [ ["#", 26, false], ["Mayor", 226, false], ["District", 176, false], ["People", 66, tru...`: Table columns: [heading, width px, right-aligned]. With the 8 px gaps they fill the panel's inner width (WIDTH less its 20 px margins). +- `LISTED_STORIES = 3` + +### Functions + +- `func setup() -> void` +- `func show_league(rows: Array[Dictionary], desk: Dictionary) -> void`: rows: ReporterDesk.standings(), best first. desk: {"day" ("Day 6 · 14:20"), "press" (the player's rank and points), "assignment" (the story's headline, "" for none), "leads" (open leads' lines, newest first), "stories" ({"text", "points", "bonus"}, newest first)}. +- `func table_lines() -> PackedStringArray`: The table's rows as text, one line per mayor ("1 · Viktor Han · Harbor Heights · ... · 78"). +- `func chase_buttons() -> Array[Button]`: The Chase buttons, newest lead first. + +## `scripts/fp/reporter/reporter_viewfinder.gd` + +extends `Control` + +The press camera's viewfinder (feature "reporter"), over the view in photo mode while a story is on assignment: corner marks round the middle of the view a press photo must hold the scene in (ReporterDesk.FRAME), green while it does, the story above the marks and what to do below them, the last shot's result under that, and a white flash for the shutter. The feature drives it through show_state(), note() and flash(). + +### Constants + +- `S = preload("res://scripts/fp/ui/hud_style.gd")` +- `DESK = preload("res://scripts/fp/reporter/reporter_desk.gd")` +- `ARM = 36.0`: Corner mark arm length and line width, and the centre cross (px). +- `LINE = 3.0` +- `CROSS = 8.0` +- `FLASH_S = 0.3`: How long the shutter flash and a shot's note last (s). +- `NOTE_S = 2.5` +- `IDLE = Color(1, 1, 1, 0.8)` +- `MISS = Color("#FFB547")` + +### Properties + +- `framed: bool`: The scene sits inside the marks now. + +### Functions + +- `func setup() -> void` +- `func show_state(title: String, hint: String, in_frame: bool) -> void`: title: the story; hint: what to do now; in_frame: the scene sits inside the marks. +- `func note(t: String, sharp: bool) -> void`: Shows the result of a shot under the hint for NOTE_S seconds (green for a sharp one). +- `func note_text() -> String`: The shot's result while it shows, else "". +- `func hint_text() -> String`: The hint line ("Click to take the shot"). +- `func flash() -> void`: The shutter: the view flashes white and fades over FLASH_S. diff --git a/docs/fp/ARCHITECTURE.md b/docs/fp/ARCHITECTURE.md index 49619ccc..38dc6a86 100644 --- a/docs/fp/ARCHITECTURE.md +++ b/docs/fp/ARCHITECTURE.md @@ -127,6 +127,7 @@ Camera ownership: A owns every camera behaviour (follow, collision, V toggle, th | **Integration: crews** | `scripts/fp/crews/**`, `tools/tests/fp_crews_test.gd`, `tools/tests/fp_crew_jobs_test.gd` | Phone orders go to the player's two building crews, which build them in real time: 2 to 5 minutes for a building at speed 1, roads by the tile. A third order waits for a free crew. Each crew is on its site to see (`crew_rig.gd`): a foreman by the gate, two builders whose clips follow the stage, and a hauler carrying crates from a pile to the work. They use the trades bodies from HC's roster (`crew_bodies.gd`, loaded in the background), with dust while they work and a board over the gate naming the crew, the order, the stage and the time left. Where they stand and walk comes from the site's `CrewGrid` (`crew_grid.gd`). It is a grid of cells 0.8 m apart with nodes on the ground inside the hoarding and on the kit's decks (on a narrow scaffold board, along its middle). Each node is clear of every collision box and cylinder near the tile, except the boxes that enclose its deck (the slab under it, the frame or scaffold round it), and of the deck's posts and of the decks above. Nodes link by steps up to 1 m and climbs over berms and formwork. `crew_spots.gd` picks the door inside the gate, the foreman's spot and the pile beside it, and work spots nearest the middle that the door reaches. It also picks a drop point the hauler reaches without walking into the crew. Where a finished building fills its lot, the crew works from the scaffold. A crew that takes an order while you play walks in through the gate. The crew is laid out again when its site is redrawn for a new stage (a worker walks over when it can), and a road crew moves along the road. A card under the top bar lists the orders with their stage, progress and countdown, and the ones waiting. E on a worker gets a word about the job, the site's interact label says which crew builds it, and a toast says when an order is done. | | **Integration: props** | `scripts/fp/props/**`, `tools/tests/fp_props_test.gd` | Street props the player's car knocks over. A bin, bench, planter, mailbox, bike rack or parking meter hit hard enough for its kind (`KINDS`: its mass, and the slowest the hit may send it flying for it to give way) falls: CityView knocks the batched prop down (not drawn, its collider marked `down`) and a copy of its meshes tumbles off (`tumble.gd`: it flies, tips over about one of its level axes, lands, rolls over its edges, slides and comes to rest on a face) over the walker's ground and off its walls. A fallen prop can be hit again. Slower hits, and lamps, trees, bollards, hydrants, utility boxes and signs, hold like walls. At most 24 lie fallen at once. A fallen prop stands up in its place again once it has lain 20 s and neither it nor its place is in sight, with the player away and no car on its place, and at once when its tile is drawn again. `prop_knocked` on the bus reports every fall (I plays the clatter). | | **Integration: body actions** | `scripts/fp/actions/**`, `tools/tests/fp_actions_test.gd`, `tools/tests/fp_chat_street_test.gd` | The hero's fists and gestures on foot (flag `actions`). Left click (right trigger) shoves whoever stands in front. With the guard up (right mouse or left trigger held: fists up, the body squared to where the camera looks, the camera arm at 80%) it throws a jab and a cross in turn, starting again from the jab a second after the last. A swing lands when the rig's fist is furthest out (`HeroRig.strike_time`, measured on the clip with `hero_fk.gd`) on the nearest person on their feet within 1.2 m in the cone ahead, through traffic's `hit_walker`, as hard as the swing (harder when running), and kicks the view. Keys 1-5 wave, nod, shake the head, shout and dance, and the people around answer (traffic's `answer_emote`). Nothing swings in a car, in photo mode or the overview, in a conversation, under a modal or with the phone open, and the click that captures the mouse throws nothing. The presses come as events; a trigger counts once per pull. The person hit (traffic's `reactions.gd`) reels (`hit_chest`, `hit_head`) and slides back from a light blow. A blow of 0.7 or more, the third punch within 8 s or a fast car knocks them down (`knockback`): they lie a while, get up (`get_up`), then shake their head or shout at whoever did it, run off or walk back to where they were. Of the people within 8 m, up to 85% (by how bad it was) react: some run off, the rest shout or shake their heads. `incidents.gd` gives a resident who was hit a memory and a mood drop (a shove 0.5, a punch 1, a car 2, half as much again when knocked down), and those who saw it a memory and a drop of 0.3. A punch that knocks someone down, or the third within 60 s, calls the police; someone a car knocks down gets an ambulance. The crew (`call_crew`, a `fleets.gd` crew) drives out from the road by the nearest police station or hospital (from a road up to 10 tiles off when the city has none), stays at the scene a while, and each kind comes at most once per 90 s. `person_hit` on the bus reports every blow: C's `street_lines.gd` posts the victim's line and a witness's (one scene per 8 s of feed time; a harder blow still posts) and a City Alert card for a crew, and I plays the blow and the fall. `hero_action` reports each swing (I's whoosh) and emote. The car the player drives strikes people too (`car_body.gd`, cause `"car"`). | +| **Integration: reporter** | `scripts/fp/reporter/**`, `tools/tests/fp_reporter_test.gd` | The player as the Gazette's stringer (flag `reporter`): a goal on foot that follows the city's AI. `reporter_desk.gd` turns AI decisions into leads: a mayor turn with a place (`mayor_decided`: its focus tile and the first order carried out, "Viktor Han ordered a Police Station"), a council ruling that changed an ordinance (at City Hall) and a Chaos Director strike. At most 4 are open, newest first, and a lead goes cold a sim day after it happened. K takes the newest on assignment and sets the GPS waypoint to the far sidewalk across its street (the overview feature's `stand_point`). A card above the district caption (`reporter_card.gd`) shows the lead or the story: what happened, where and how far, the scoop clock, the quote so far and a checklist (the scene, a quote, an optional photo). Within 3 tiles of the scene, a reply face to face or the "Ask someone here" answer (`talk_replied`, emitted by C) is the quote: 12 characters or more, clipped to 140. A resident who lives in the deciding mayor's district is a local voice, and a passer-by never replaces one. In photo mode (`reporter_viewfinder.gd`) a click from within 4 tiles (80 m) with the scene inside the central 65% of the view is a sharp shot; the hero must have stood at the scene when the camera came up. K files the story: 100 points, +50 for a scoop (within a third of a day), +25 for a local voice and +25 for a sharp shot, towards the press ranks (Stringer, Reporter at 300, Senior reporter at 1000, Star reporter at 2500). The resident quoted remembers it, and `story_filed` has C run it as a Gazette line and a chatter topic. L opens the mayors' league (`reporter_league.gd`, modal `league`): the five mayors ranked on people, jobs, approval (what the district's residents think of their mayor) and funds, 25 points each, with their model's badge; then the press card, the story on assignment, the open leads with a Chase button each, and the latest stories. Points, stories filed and the first-lead tip are saved; leads and the assignment belong to the city on screen and are dropped on load and New City. | ## 6. Rules for every slice diff --git a/docs/fp/S0-foundation.md b/docs/fp/S0-foundation.md index fad48727..06af00cd 100644 --- a/docs/fp/S0-foundation.md +++ b/docs/fp/S0-foundation.md @@ -695,6 +695,14 @@ signal person_hit(at: Vector3, cause: String, info: Dictionary) ## The hero swung or gestured: action "jab", "cross", "shove" or an emote ("wave", "yes", "no", ## "shout", "dance"), at their feet. Emitted by the actions feature. signal hero_action(action: String, at: Vector3) +## Someone answered the hero: a resident face to face (their id and name), or whoever answered +## "Ask someone here" (resident_id "" and their role, "Shopkeeper"). reply is filtered for +## display. Emitted by chat. +signal talk_replied(resident_id: String, speaker: String, reply: String) +## The player filed a story for the Gazette: reporter_feature.gd file_story()'s {"agent", +## "headline", "place", "quote", "by", "tile", "district", "day", "scoop", "local", "sharp", +## "points", "text"}. Emitted by the reporter feature; chat runs "text" as a Gazette line. +signal story_filed(story: Dictionary) ``` | Signal | Payload | Emitted by | Typical listeners | @@ -721,6 +729,8 @@ signal hero_action(action: String, at: Vector3) | `prop_knocked` | `at`: where the car hit it. `kind`: its StreetDressing kind. `speed`: how fast it flies off (u/s); a fallen prop hit again reports from 1 m/s | Integration: props | I (clatter or clang by kind) | | `person_hit` | `at`: the person's drawn feet. `cause`: `"shove"` or `"punch"` (the hero) or `"car"` (the car the player drives). `info`: `walker` (the walker id), `resident_id` and `name` (`""` for an ambient walker), `down` (knocked down), `street` (the street label, may be `""`), `strength` 0..1, `witnesses` (the resident ids of those who saw it), `crew` (`"police"`, `"ambulance"` or `""`: the crew this blow called) | E (`hit_walker`, called by Integration: body actions and by the drive feature's car body) | C (the victim's and a witness's lines, a crew's City Alert card), I (the blow and the fall) | | `hero_action` | `action`: `"jab"`, `"cross"` or `"shove"` as a swing starts, or the emote as it starts (`"wave"`, `"yes"`, `"no"`, `"shout"`, `"dance"`). `at`: the hero's feet | Integration: body actions | I (the whoosh of a swing) | +| `talk_replied` | `resident_id`: the resident's id, `""` for the "Ask someone here" answer. `speaker`: the resident's name, or the role that answered (`"Shopkeeper"`). `reply`: the line as shown | C (`talk_dialog.gd`: each reply face to face, and the ask answer) | Integration: reporter (the quote for the story on assignment) | +| `story_filed` | the story as filed: `agent` (`"0"`..`"4"`, `"council"` or `"chaos"`), `headline`, `place`, `quote`, `by`, `tile` (index), `district`, `day`, `scoop`, `local`, `sharp`, `points`, `text` (the Gazette's line) | Integration: reporter (K files) | C (a Gazette line in the chat and a chatter topic) | The **target** dict: `{"kind": "resident"|"building"|"poster"|"vehicle"|"pedestrian", "id": String, "tile": int, "pos": Vector3, "dist": float, "label": String, "data": Dictionary}`. diff --git a/project.godot b/project.godot index 2268c3c0..b579efe9 100644 --- a/project.godot +++ b/project.godot @@ -307,6 +307,16 @@ emote_dance={ "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":53,"key_label":0,"unicode":53,"location":0,"echo":false,"script":null) ] } +press_story={ +"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":75,"key_label":0,"unicode":107,"location":0,"echo":false,"script":null) +] +} +press_league={ +"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":76,"key_label":0,"unicode":108,"location":0,"echo":false,"script":null) +] +} [rendering] diff --git a/scripts/fp/chat/chat_feature.gd b/scripts/fp/chat/chat_feature.gd index 2e692424..2e1d063d 100644 --- a/scripts/fp/chat/chat_feature.gd +++ b/scripts/fp/chat/chat_feature.gd @@ -137,6 +137,7 @@ func setup(p_ctx: FpContext) -> void: _link(ctx.bus.pause_changed, _on_pause_changed) _link(ctx.bus.world_ready, _on_world_ready) _link(ctx.bus.person_hit, street_lines.on_person_hit) + _link(ctx.bus.story_filed, _on_story) _snapshot_taxes() @@ -726,6 +727,18 @@ func _on_card_action(action: String, tile: int) -> void: ctx.brain.inspect_tile(D.tx(tile), D.ty(tile)) +## The player's story for the Gazette (bus.story_filed), as a press line of its own: each story +## has its own author id, so a second one never replaces the first while it waits in the queue. +func _on_story(s: Dictionary) -> void: + var text := str(s.get("text", "")) + var refs := {"day": int(s.get("day", 0)), "tile": int(s.get("tile", -1))} + var d := int(s.get("district", -1)) + var id := "press:story:%d" % hash(text) + feed.enqueue(feed.make(id, "GridFall Gazette", "press", d, text, "headline", -1, refs)) + if not str(s.get("headline", "")).is_empty(): + _push_topic({"kind": "headline", "headline": str(s["headline"])}) + + func _on_player_tile_changed(_x: int, _y: int) -> void: _refresh_district() diff --git a/scripts/fp/chat/talk_dialog.gd b/scripts/fp/chat/talk_dialog.gd index f7db3550..fb152cd0 100644 --- a/scripts/fp/chat/talk_dialog.gd +++ b/scripts/fp/chat/talk_dialog.gd @@ -140,6 +140,8 @@ func set_ask_answer(speaker: String, place: String, says: String) -> void: _name_l.text = speaker _sub_l.text = place _show_line(says, "none", "") + if ctx.bus != null: + ctx.bus.talk_replied.emit("", speaker, says) ## Ends the conversation (or closes the ask card): emits exactly one conversation_changed(id, @@ -281,6 +283,8 @@ func _receive(res: Dictionary) -> void: reply = "Hm." _turns.append({"who": "them", "text": reply}) _show_line(reply, str(res.get("emote", "none")), _last_said) + if ctx.bus != null: + ctx.bus.talk_replied.emit(active_id, _name, reply) var delta := clampf(float(res.get("mood_delta", 0.0)), -1.0, 1.0) var allowed := clampf(_mood_total + delta, -1.0, 1.0) - _mood_total if absf(allowed) > 0.0: diff --git a/scripts/fp/fp_features.gd b/scripts/fp/fp_features.gd index d8f4f5f2..eb869a79 100644 --- a/scripts/fp/fp_features.gd +++ b/scripts/fp/fp_features.gd @@ -24,4 +24,8 @@ const FEATURES := [ "id": "actions", "slice": "integration", "path": "res://scripts/fp/actions/actions_feature.gd", }, + { + "id": "reporter", "slice": "integration", + "path": "res://scripts/fp/reporter/reporter_feature.gd", + }, ] diff --git a/scripts/fp/game_bus.gd b/scripts/fp/game_bus.gd index 9e63fd38..18e46aed 100644 --- a/scripts/fp/game_bus.gd +++ b/scripts/fp/game_bus.gd @@ -54,3 +54,11 @@ signal person_hit(at: Vector3, cause: String, info: Dictionary) ## The hero swung or gestured: action "jab", "cross", "shove" or an emote ("wave", "yes", "no", ## "shout", "dance"), at their feet. Emitted by the actions feature. signal hero_action(action: String, at: Vector3) +## Someone answered the hero: a resident face to face (their id and name), or whoever answered +## "Ask someone here" (resident_id "" and their role, "Shopkeeper"). reply is filtered for +## display. Emitted by chat. +signal talk_replied(resident_id: String, speaker: String, reply: String) +## The player filed a story for the Gazette: reporter_feature.gd file_story()'s {"agent", +## "headline", "place", "quote", "by", "tile", "district", "day", "scoop", "local", "sharp", +## "points", "text"}. Emitted by the reporter feature; chat runs "text" as a Gazette line. +signal story_filed(story: Dictionary) diff --git a/scripts/fp/reporter/reporter_card.gd b/scripts/fp/reporter/reporter_card.gd new file mode 100644 index 00000000..9388f87e --- /dev/null +++ b/scripts/fp/reporter/reporter_card.gd @@ -0,0 +1,177 @@ +extends Control +## The Gazette card on the HUD (feature "reporter"), bottom right above the district caption: the +## newest lead and the key to take it, or the story on assignment: what happened, where and how +## far, the scoop clock, the quote so far and what the story still needs (the scene, a quote, a +## photo), with the key to file it. The feature fills it through show_state(); nothing here reads +## the sim. + +const S := preload("res://scripts/fp/ui/hud_style.gd") + +const WIDTH := 320.0 +## Text width inside the card: WIDTH less the 12 px side margins and the 3 px stripe. +const INNER := WIDTH - 27.0 +const SAFE := 24.0 +## The bottom edge, clear of the district caption (90 px tall over the bottom safe margin). +const FROM_BOTTOM := SAFE + 104.0 +const BG := Color(0.03, 0.04, 0.07, 0.78) +const TICK := 9.0 + +var _stripe: StyleBoxFlat +var _kicker: Label +var _rank: Label +var _headline: Label +var _where: Label +var _quote: Label +var _steps: VBoxContainer +## One per step row: {"row", "box" (the tick's StyleBoxFlat), "label"}. +var _ticks: Array[Dictionary] = [] +var _foot: HBoxContainer +var _hint: Label + + +func setup() -> void: + name = "ReporterCard" + mouse_filter = Control.MOUSE_FILTER_IGNORE + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + var panel := PanelContainer.new() + panel.name = "Card" + panel.mouse_filter = Control.MOUSE_FILTER_IGNORE + _stripe = S.stripe(S.box(BG, S.RADIUS, 12, 8), S.AMBER, 3) + panel.add_theme_stylebox_override("panel", _stripe) + panel.anchor_left = 1.0 + panel.anchor_right = 1.0 + panel.anchor_top = 1.0 + panel.anchor_bottom = 1.0 + panel.offset_left = -SAFE - WIDTH + panel.offset_right = -SAFE + panel.offset_top = -FROM_BOTTOM + panel.offset_bottom = -FROM_BOTTOM + panel.grow_horizontal = Control.GROW_DIRECTION_BEGIN + panel.grow_vertical = Control.GROW_DIRECTION_BEGIN + add_child(panel) + var col := VBoxContainer.new() + col.mouse_filter = Control.MOUSE_FILTER_IGNORE + col.add_theme_constant_override("separation", 4) + panel.add_child(col) + var head := _hbox(8) + col.add_child(head) + _kicker = S.caps_label("", 12, S.AMBER, 0) + _kicker.name = "Kicker" + _kicker.size_flags_horizontal = Control.SIZE_EXPAND_FILL + head.add_child(_kicker) + _rank = S.plain("", 12, S.TEXT_DIM, S.font()) + _rank.name = "Rank" + head.add_child(_rank) + _headline = _wrapped(S.plain("", 16, S.WHITE, S.bold()), "Headline") + col.add_child(_headline) + _where = S.plain("", 12, S.TEXT_DIM, S.font()) + _where.name = "Where" + _where.clip_text = true + _where.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + _where.custom_minimum_size = Vector2(INNER, 0) + col.add_child(_where) + _quote = _wrapped(S.plain("", 13, S.TEXT, S.font()), "Quote") + col.add_child(_quote) + _steps = VBoxContainer.new() + _steps.name = "Steps" + _steps.mouse_filter = Control.MOUSE_FILTER_IGNORE + _steps.add_theme_constant_override("separation", 2) + col.add_child(_steps) + _foot = _hbox(6) + _foot.name = "Hint" + var key := S.key_cap("K", 11) + key.size_flags_vertical = Control.SIZE_SHRINK_CENTER + _foot.add_child(key) + _hint = S.caps_label("", 11, S.TEXT, 2) + _foot.add_child(_hint) + col.add_child(_foot) + + +## st: {"kicker" ("New lead", "On assignment", "Ready to file"), "rank" ("Stringer · 120"), +## "color" (the deciding agent's), "headline", "where" ("214 Maple Street · 240 m · scoop 4:10"), +## "quote" ("" for none), "steps" ([text, done] pairs; none for a lead), "hint" (what K does now; +## "" hides the key)}. +func show_state(st: Dictionary) -> void: + _set_text(_kicker, str(st.get("kicker", ""))) + _set_text(_rank, str(st.get("rank", ""))) + _set_text(_headline, str(st.get("headline", ""))) + _set_text(_where, str(st.get("where", ""))) + _set_text(_quote, str(st.get("quote", ""))) + _quote.visible = not _quote.text.is_empty() + _stripe.border_color = st.get("color", S.AMBER) + _show_steps(st.get("steps", [])) + _set_text(_hint, str(st.get("hint", ""))) + _foot.visible = not _hint.text.is_empty() + + +## Everything the card says now, " · " between the parts (the step rows read "[x] At the scene"). +func card_text() -> String: + var parts := PackedStringArray() + for l: Label in [_kicker, _rank, _headline, _where, _quote]: + if l.visible and not l.text.is_empty(): + parts.append(l.text) + for t: Dictionary in _ticks: + if (t["row"] as Control).visible: + var done := (t["box"] as StyleBoxFlat).bg_color == S.GOOD + parts.append(("[x] " if done else "[ ] ") + (t["label"] as Label).text) + if _foot.visible: + parts.append("K " + _hint.text) + return " · ".join(parts) + + +func _show_steps(steps: Array) -> void: + while _ticks.size() < steps.size(): + _ticks.append(_make_step()) + for k in range(_ticks.size()): + var t: Dictionary = _ticks[k] + var shown := k < steps.size() + (t["row"] as Control).visible = shown + if not shown: + continue + var done := bool(steps[k][1]) + var sb: StyleBoxFlat = t["box"] + sb.bg_color = S.GOOD if done else Color(0, 0, 0, 0) + sb.border_color = S.GOOD if done else S.TEXT_FAINT + var l: Label = t["label"] + _set_text(l, str(steps[k][0])) + l.add_theme_color_override("font_color", S.TEXT if done else S.TEXT_DIM) + _steps.visible = not steps.is_empty() + + +func _make_step() -> Dictionary: + var row := _hbox(7) + var tick := Panel.new() + tick.mouse_filter = Control.MOUSE_FILTER_IGNORE + tick.custom_minimum_size = Vector2(TICK, TICK) + tick.size_flags_vertical = Control.SIZE_SHRINK_CENTER + var sb := S.box(Color(0, 0, 0, 0), S.RADIUS, 0, 0, S.TEXT_FAINT, 1) + tick.add_theme_stylebox_override("panel", sb) + row.add_child(tick) + var l := S.plain("", 13, S.TEXT_DIM, S.font()) + l.clip_text = true + l.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + l.custom_minimum_size = Vector2(INNER - TICK - 7.0, 0) + row.add_child(l) + _steps.add_child(row) + return {"row": row, "box": sb, "label": l} + + +static func _wrapped(l: Label, node_name: String) -> Label: + l.name = node_name + l.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + l.max_lines_visible = 2 + l.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + l.custom_minimum_size = Vector2(INNER, 0) + return l + + +static func _set_text(l: Label, t: String) -> void: + if l.text != t: + l.text = t + + +static func _hbox(sep: int) -> HBoxContainer: + var row := HBoxContainer.new() + row.mouse_filter = Control.MOUSE_FILTER_IGNORE + row.add_theme_constant_override("separation", sep) + return row diff --git a/scripts/fp/reporter/reporter_card.gd.uid b/scripts/fp/reporter/reporter_card.gd.uid new file mode 100644 index 00000000..e05ed4e5 --- /dev/null +++ b/scripts/fp/reporter/reporter_card.gd.uid @@ -0,0 +1 @@ +uid://dbhrgfiqmrayx diff --git a/scripts/fp/reporter/reporter_desk.gd b/scripts/fp/reporter/reporter_desk.gd new file mode 100644 index 00000000..5e4e75b5 --- /dev/null +++ b/scripts/fp/reporter/reporter_desk.gd @@ -0,0 +1,231 @@ +extends RefCounted +## The Gazette's desk (feature "reporter"): which AI decisions make a lead and how they read +## ("Viktor Han ordered a Police Station"), what a filed story says and earns, the press ranks, +## what a photo must frame, and the mayors' league (people, jobs, approval and funds, worth 25 +## points each). No nodes. + +const D := preload("res://scripts/sim/sim_defs.gd") + +## Points for a filed story, and the bonuses on top: a scoop (filed fast), a local voice (the quote +## comes from someone who lives in the deciding mayor's district) and a sharp shot (a photo that +## frames the scene). +const FILED := 100 +const SCOOP := 50 +const LOCAL := 25 +const SHARP := 25 +## Press ranks, [points needed, name], lowest first. +const RANKS := [ + [0, "Stringer"], [300, "Reporter"], [1000, "Senior reporter"], [2500, "Star reporter"] +] +const ZONED := {"R": "zoned new homes", "C": "zoned new shops", "I": "zoned new industry"} +const VAGUE := "made a decision here" +## Longest quote kept, in characters. +const QUOTE_CHARS := 140 +## A league category is worth this many points to the best mayor in it (approval: a 10 out of 10). +const CATEGORY := 25.0 +## A shot frames its subject when the subject sits inside this central part of the view (a +## fraction of its width and of its height). +const FRAME := 0.65 + + +## The lead in a mayor's turn: what they did at its focus tile, the first order carried out that +## has a place. {} when the turn has none. A lead is {"agent" ("0".."4", "council" or "chaos"), +## "headline", "tile": Vector2i, "district" (the deciding mayor; -1 for the others)}. +static func mayor_lead(mi: int, entry: Dictionary) -> Dictionary: + var focus: Variant = entry.get("focus", D.NO_TILE) + if mi < 0 or mi >= D.MAYORS.size() or typeof(focus) != TYPE_VECTOR2I: + return {} + var did := VAGUE + var raw: Variant = entry.get("actions", []) + var applied: Array = raw if typeof(raw) == TYPE_ARRAY else [] + for v: Variant in applied: + if typeof(v) != TYPE_DICTIONARY or not bool((v as Dictionary).get("ok", false)): + continue + var a: Variant = (v as Dictionary).get("action", {}) + if typeof(a) == TYPE_DICTIONARY and (a as Dictionary).has("x"): + did = deed(a) + break + return _lead(str(mi), "%s %s" % [D.mayor_display(mi), did], focus, mi) + + +## The lead in a council ruling that passed or repealed an ordinance, at City Hall (hall); {} when +## the council changed nothing. +static func council_lead(r: Dictionary, hall: Vector2i) -> Dictionary: + for pair: Array in [["enacted", "passed"], ["repealed", "repealed"]]: + var raw: Variant = r.get(pair[0], []) + var keys: Array = raw if typeof(raw) == TYPE_ARRAY else [] + for k: Variant in keys: + var o: Dictionary = D.ORDINANCES.get(str(k), {}) + if not o.is_empty(): + return _lead("council", "The council %s the %s" % [pair[1], o["label"]], hall, -1) + return {} + + +## The lead in a Chaos Director pick that struck somewhere ("Building Fire reported"); {} for a +## calm day or a pick without a place. +static func chaos_lead(d: Dictionary) -> Dictionary: + if not bool(d.get("trigger", false)) or not d.has("x"): + return {} + var ev: Dictionary = D.DISASTERS.get(str(d.get("type", "")), {}) + var at := Vector2i(int(d["x"]), int(d.get("y", 0))) + return _lead("chaos", "%s reported" % str(ev.get("label", "An incident")), at, -1) + + +## What one order did, as the Gazette puts it ("ordered a Police Station", "zoned new homes"). +static func deed(a: Dictionary) -> String: + var out := VAGUE + match str(a.get("type", "")).to_lower(): + "build": + var b: Dictionary = D.BUILDINGS.get(str(a.get("building", "")).to_lower(), {}) + out = "ordered " + _article(str(b.get("label", "building"))) + "zone": + out = str(ZONED.get(str(a.get("zone", "R")).to_upper().left(1), ZONED["R"])) + "road": + out = "laid a new road" + "bulldoze": + out = "cleared a block" + "emergency": + out = "sent in emergency crews" + "annex": + out = "annexed new land" + return out + + +## The agent's colour (a mayor's or a support agent's). +static func color_of(agent: String) -> Color: + var hex := "#FFD27F" + if agent.is_valid_int() and int(agent) >= 0 and int(agent) < D.MAYORS.size(): + hex = str(D.MAYORS[int(agent)]["color"]) + elif D.SUPPORT_AGENTS.has(agent): + hex = str(D.SUPPORT_AGENTS[agent]["color"]) + return Color(hex) + + +## A reply as a quote: de-branded, on one line, at most QUOTE_CHARS (cut at a word, with "…"). +static func clip(t: String) -> String: + var s := D.debrand(t.strip_edges()).replace("\n", " ") + if s.length() <= QUOTE_CHARS: + return s + var cut := s.left(QUOTE_CHARS - 1) + var space := cut.rfind(" ") + if space > QUOTE_CHARS * 0.5: + cut = cut.left(space) + return cut.rstrip(" ,.;:") + "…" + + +## The byline of a passer-by who answered "Ask someone here": a role reads "a shopkeeper" or "an +## officer", a name ("Mara Ellis") stays as it is, and nobody reads "someone nearby". +static func passerby(speaker: String) -> String: + var s := speaker.strip_edges() + if s.is_empty(): + return "someone nearby" + var words := s.split(" ", false) + var named := words.size() > 1 + for w: String in words: + var c := w.left(1) + named = named and c != c.to_lower() + return s if named else _article(s.to_lower()) + + +## The story as the Gazette runs it in the chat: "From 214 Maple Street: Viktor Han ordered a +## Police Station. “About time,” says Ana Ruiz." +static func story_text(s: Dictionary) -> String: + var out := "From %s: %s." % [s.get("place", "the city"), s.get("headline", "")] + var q := str(s.get("quote", "")) + if q.is_empty(): + return out + var by := str(s.get("by", "")) + by = "someone nearby" if by.is_empty() else by + if q.right(1) in ["!", "?", "…"]: + return out + " “%s” says %s." % [q, by] + return out + " “%s,” says %s." % [q.trim_suffix("."), by] + + +## Points for a story: FILED, and SCOOP, LOCAL and SHARP for each bonus it earned. +static func points_for(scoop: bool, local: bool, sharp: bool) -> int: + return FILED + (SCOOP if scoop else 0) + (LOCAL if local else 0) + (SHARP if sharp else 0) + + +## The bonuses a story earned, in words ("scoop, local voice"); "" for none. +static func bonus_words(s: Dictionary) -> String: + var parts := PackedStringArray() + for pair: Array in [["scoop", "scoop"], ["local", "local voice"], ["sharp", "sharp shot"]]: + if bool(s.get(pair[0], false)): + parts.append(pair[1]) + return ", ".join(parts) + + +## The press rank for points: {"name", "next" (the points the next rank needs, -1 at the top)}. +static func rank_of(points: int) -> Dictionary: + var k := 0 + for n in range(RANKS.size()): + if points >= int(RANKS[n][0]): + k = n + var next := int(RANKS[k + 1][0]) if k + 1 < RANKS.size() else -1 + return {"name": str(RANKS[k][1]), "next": next} + + +## Whether screen point p (a camera's unproject_position) lies in the central FRAME of a view of +## this size. +static func framed(p: Vector2, view: Vector2) -> bool: + var m := view * (1.0 - FRAME) * 0.5 + return Rect2(m, view - m * 2.0).has_point(p) + + +## The league table, best first: a row per mayor {"mi", "who", "model", "style", "district", +## "color", "pop", "jobs", "approval" (0-10: what the district's residents think of their mayor, +## else its happiness), "funds", "orders" (carried out), "refused", "score" (0-100)}. People, jobs +## and funds score against the best mayor, approval out of 10. Ties go to the bigger district. +static func standings(sim: CitySim, people: Residents) -> Array[Dictionary]: + var rows: Array[Dictionary] = [] + if sim == null: + return rows + var best := {"pop": 1.0, "jobs": 1.0, "funds": 1.0} + for mi in range(mini(D.MAYORS.size(), sim.mayors.size())): + var m: Dictionary = sim.mayors[mi] + var row := {"mi": mi, "who": D.mayor_display(mi), "model": str(D.MAYORS[mi]["name"])} + row["style"] = str(D.MAYORS[mi]["style"]) + row["district"] = str(D.MAYORS[mi]["district"]) + row["color"] = color_of(str(mi)) + row["pop"] = int(m.get("pop", 0)) + row["jobs"] = int(m.get("jobs", 0)) + row["funds"] = int(m.get("treasury", 0)) + row["orders"] = int(m.get("actions_ok", 0)) + row["refused"] = int(m.get("actions_rejected", 0)) + row["approval"] = _approval(mi, m, people) + for k: String in best.keys(): + best[k] = maxf(float(best[k]), float(row[k])) + rows.append(row) + for row: Dictionary in rows: + var share := float(row["pop"]) / float(best["pop"]) + float(row["approval"]) / 10.0 + share += float(row["jobs"]) / float(best["jobs"]) + share += maxf(float(row["funds"]), 0.0) / float(best["funds"]) + row["score"] = roundi(CATEGORY * share) + rows.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return _ahead(a, b)) + return rows + + +static func _approval(mi: int, m: Dictionary, people: Residents) -> float: + if people != null and people.is_active(): + var mood := people.district_mood(mi) + if int(mood.get("residents", 0)) > 0: + return clampf(float(mood.get("approval", 0.0)), 0.0, 10.0) + return snappedf(clampf(float(m.get("happiness", 0.5)), 0.0, 1.0) * 10.0, 0.1) + + +static func _ahead(a: Dictionary, b: Dictionary) -> bool: + if int(a["score"]) != int(b["score"]): + return int(a["score"]) > int(b["score"]) + if int(a["pop"]) != int(b["pop"]): + return int(a["pop"]) > int(b["pop"]) + return int(a["mi"]) < int(b["mi"]) + + +static func _lead(agent: String, headline: String, at: Vector2i, district: int) -> Dictionary: + if not D.in_bounds(at.x, at.y): + return {} + return {"agent": agent, "headline": D.debrand(headline), "tile": at, "district": district} + + +static func _article(noun: String) -> String: + return ("an " if noun.left(1).to_lower() in ["a", "e", "i", "o", "u"] else "a ") + noun diff --git a/scripts/fp/reporter/reporter_desk.gd.uid b/scripts/fp/reporter/reporter_desk.gd.uid new file mode 100644 index 00000000..bf584d48 --- /dev/null +++ b/scripts/fp/reporter/reporter_desk.gd.uid @@ -0,0 +1 @@ +uid://1a2cobrybwvo diff --git a/scripts/fp/reporter/reporter_feature.gd b/scripts/fp/reporter/reporter_feature.gd new file mode 100644 index 00000000..35b73065 --- /dev/null +++ b/scripts/fp/reporter/reporter_feature.gd @@ -0,0 +1,729 @@ +extends FpFeature +## The Gazette's stringer (integration feature "reporter"; docs/fp/ARCHITECTURE.md, Reporter). An +## AI decision with a place is a lead (ReporterDesk): a mayor's order, a council ruling, a Chaos +## Director strike. K takes the newest on assignment and sets a waypoint to the scene. There the +## story needs a quote: a reply someone gives the hero face to face, or the answer to "Ask +## someone here" (bus.talk_replied). It may also carry a photo: in photo mode (P) a click with the +## scene inside the viewfinder's marks is a sharp shot. K files it: the Gazette runs it in the +## chat (bus.story_filed) and it earns points towards the press ranks, more for a scoop (filed +## within a third of a day), a local voice (someone who lives in the district) and a sharp shot. +## L opens the mayors' league: the five mayors ranked, the press card, the open leads (chase +## another) and the latest stories. A lead goes cold after a day. + +const D := preload("res://scripts/sim/sim_defs.gd") +const S := preload("res://scripts/fp/ui/hud_style.gd") +const DESK := preload("res://scripts/fp/reporter/reporter_desk.gd") +const CARD := preload("res://scripts/fp/reporter/reporter_card.gd") +const FINDER := preload("res://scripts/fp/reporter/reporter_viewfinder.gd") +const LEAGUE := preload("res://scripts/fp/reporter/reporter_league.gd") +const MODAL := &"league" +const INPUTS: Array[String] = ["press_story", "press_league"] +const MAX_LEADS := 4 +const KEEP_STORIES := 5 +## A lead goes cold LEAD_DAYS after it happened; a story filed within SCOOP_DAYS is a scoop (sim +## days). +const LEAD_DAYS := 1.0 +const SCOOP_DAYS := 1.0 / 3.0 +## The scene: within SCENE tiles of the lead's tile. A press photo holds a point SUBJECT_Y above +## the tile's ground, from within PHOTO_RANGE (u). +const SCENE := 3.0 +const SUBJECT_Y := 0.15 +const PHOTO_RANGE := 4.0 +## A reply shorter than this is no quote ("Hm."). +const QUOTE_MIN := 12 +const REFRESH := 0.5 +## Where a council ruling happens when the city has no City Hall. +const HALL_AT := Vector2i(15, 14) +const NO_POINT := Vector3(NAN, NAN, NAN) +## Photo tour: frames to wait for the tour's teleport before staging, and the staged answer. +const PHOTO_FRAMES := 8 +const PHOTO_SPEAKER := "Shopkeeper" +const PHOTO_QUOTE := "Nobody asked us first, but honestly, it's about time." + +## Open leads, newest first: ReporterDesk leads with "at" (sim days) and "place". +var leads: Array[Dictionary] = [] +## The lead on assignment with its "quote", "by", "quote_id" (the resident's id, "" for a +## passer-by), "local", "sharp", "seen" (the hero reached the scene) and "wp" (the waypoint set +## for it); {} for none. +var story: Dictionary = {} +var points: int = 0 +var filed: int = 0 +## The latest filed stories, newest first: {"text", "points", "bonus", "day"}. +var stories: Array[Dictionary] = [] +var league_open: bool = false +var card: CARD +var finder: FINDER +var league: LEAGUE +var _tipped: bool = false +## The hero's feet when photo mode opened: the camera flies off, the scene is judged from here. +var _anchor: Vector3 = NO_POINT +## The strike trigger is past its dead zone (the shutter fires once per pull). +var _trigger: bool = false +var _t: float = 0.0 +## The photo tour's lead, {} without one. +var _photo: Dictionary = {} + + +func feature_id() -> String: + return "reporter" + + +func setup(p_ctx: FpContext) -> void: + super.setup(p_ctx) + if ctx.hud != null: + _build_ui() + if ctx.brain != null: + link_signal(ctx.brain.mayor_decided, _on_mayor) + link_signal(ctx.brain.council_ruled, _on_council) + link_signal(ctx.brain.chaos_decided, _on_chaos) + if ctx.bus != null: + link_signal(ctx.bus.talk_replied, _on_reply) + link_signal(ctx.bus.modal_changed, _on_modal) + link_signal(ctx.bus.pause_changed, _on_pause) + if ctx.player != null: + link_signal(ctx.player.photo_mode_changed, _on_photo) + + +## Boot, load or New City: the old city's leads and story are gone. +func on_world_rebuilt() -> void: + _drop_all() + + +func to_dict() -> Dictionary: + if points == 0 and filed == 0 and not _tipped: + return {} + return {"points": points, "filed": filed, "stories": stories.duplicate(true), "tipped": _tipped} + + +func from_dict(d: Dictionary) -> void: + points = maxi(0, int(d.get("points", 0))) + filed = maxi(0, int(d.get("filed", 0))) + _tipped = bool(d.get("tipped", false)) + stories.clear() + var raw: Variant = d.get("stories", []) + var list: Array = raw if typeof(raw) == TYPE_ARRAY else [] + for v: Variant in list: + if typeof(v) == TYPE_DICTIONARY and stories.size() < KEEP_STORIES: + stories.append(_kept(v)) + _drop_all() + + +func teardown() -> void: + close_league() + unlink_signals() + for n: Control in [card, finder, league]: + if n != null and is_instance_valid(n): + n.queue_free() + card = null + finder = null + league = null + + +func self_test() -> Dictionary: + var missing := INPUTS.filter(func(a: String) -> bool: return not InputMap.has_action(a)) + if not missing.is_empty(): + return {"ok": false, "detail": "missing inputs %s" % str(missing)} + if ctx.hud != null and (card == null or finder == null or league == null): + return {"ok": false, "detail": "reporter UI missing"} + var rank := str(DESK.rank_of(points)["name"]) + return {"ok": true, "detail": "%d leads, %d filed, %s" % [leads.size(), filed, rank]} + + +## Takes open lead k (0: the newest) on assignment, dropping any story already on it, and sets a +## waypoint to its scene. False when there is no such lead. +func take_lead(k: int = 0) -> bool: + if k < 0 or k >= leads.size(): + if leads.is_empty(): + _toast("No leads yet. Every decision a mayor makes somewhere brings one.", "info") + return false + _clear_story() + story = leads[k].duplicate() + leads.remove_at(k) + story.merge({"quote": "", "by": "", "quote_id": "", "local": false, "sharp": false}) + story["seen"] = at_scene() + var stand := _stand(story) + story["wp"] = stand["pos"] + if ctx.hud != null and not bool(story["seen"]): + ctx.hud.set_waypoint(stand["pos"]) + _toast("On assignment: %s. Get a quote at %s." % [story["headline"], story["place"]], "info") + Analytics.track("lead_taken", {"agent": str(story["agent"]), "open": leads.size()}) + _refresh() + return true + + +## K: takes the newest lead, or files the story on assignment once it has its quote (else says what +## it still needs). +func press_story() -> void: + if story.is_empty(): + take_lead() + elif not str(story["quote"]).is_empty(): + file_story() + elif bool(story["seen"]): + _toast("Not filed yet: talk to someone here for a quote. L lists the other leads.", "info") + else: + var t := "Not filed yet: get to %s for a quote. L lists the other leads." % story["place"] + _toast(t, "info") + + +## Files the story on assignment once it has its quote: points towards the press ranks, the +## Gazette's chat line (bus.story_filed) and a memory for the resident quoted. Returns the story +## as filed (bus.story_filed's dict), {} when there is nothing to file. +func file_story() -> Dictionary: + if story.is_empty() or str(story.get("quote", "")).is_empty(): + return {} + var s := story + _clear_story() + var t: Vector2i = s["tile"] + var scoop := _now() - float(s["at"]) <= SCOOP_DAYS + var out := {"agent": s["agent"], "headline": s["headline"], "place": s["place"]} + out.merge({"quote": s["quote"], "by": s["by"], "tile": D.idx(t.x, t.y)}) + out.merge({"district": int(s["district"]), "day": ctx.sim.day if ctx.sim != null else 0}) + out.merge({"scoop": scoop, "local": bool(s["local"]), "sharp": bool(s["sharp"])}) + out["points"] = DESK.points_for(scoop, bool(s["local"]), bool(s["sharp"])) + out["text"] = DESK.story_text(out) + var was := str(DESK.rank_of(points)["name"]) + points += int(out["points"]) + filed += 1 + stories.push_front(_kept(out)) + if stories.size() > KEEP_STORIES: + stories.resize(KEEP_STORIES) + _credit(s) + _announce(out, was) + if ctx.bus != null: + ctx.bus.story_filed.emit(out) + var tags := {"points": out["points"], "scoop": scoop, "local": out["local"]} + tags["sharp"] = out["sharp"] + Analytics.track("story_filed", tags) + _refresh() + return out + + +## True while the hero is at the scene of the story on assignment (in photo mode: stood there when +## the camera came up). +func at_scene() -> bool: + if story.is_empty(): + return false + var feet := ctx.player_position() + if ctx.player != null and ctx.player.photo_mode and not is_nan(_anchor.x): + feet = _anchor + var t: Vector2i = story["tile"] + return Vector2(feet.x - t.x, feet.z - t.y).length() <= SCENE + + +## True when the camera holds the scene of the story on assignment inside the viewfinder's marks +## (ReporterDesk.framed), from within PHOTO_RANGE. +func scene_framed() -> bool: + var cam: Camera3D = ctx.player.camera if ctx.player != null else null + if story.is_empty() or cam == null or not cam.is_inside_tree(): + return false + var at := _subject(story) + if cam.global_position.distance_to(at) > PHOTO_RANGE or cam.is_position_behind(at): + return false + return DESK.framed(cam.unproject_position(at), cam.get_viewport().get_visible_rect().size) + + +## The shutter (a click in photo mode on an assignment): a sharp shot when the hero stands at the +## scene and the camera holds it inside the marks. True for a sharp shot. +func snap() -> bool: + if story.is_empty() or ctx.player == null or not ctx.player.photo_mode: + return false + var near := at_scene() + var sharp := near and scene_framed() + var note := "Missed it: hold the scene inside the marks." + if sharp: + var again := bool(story["sharp"]) + note = "Another sharp one." if again else "Sharp shot. It runs with the story." + story["sharp"] = true + elif not near: + note = "Too far from the scene for a press photo." + if finder != null: + finder.flash() + finder.note(note, sharp) + Analytics.track("press_photo", {"sharp": sharp}) + _refresh() + return sharp + + +## Opens the mayors' league (L). False when it may not open now: no HUD, a modal open, paused, in +## photo mode or talking. +func open_league() -> bool: + if league_open or league == null or ctx.player == null or ctx.is_modal(): + return false + if (ctx.mode != null and ctx.mode.paused) or ctx.player.photo_mode: + return false + if ctx.player.in_conversation(): + return false + league_open = true + ctx.push_modal(MODAL) + _fill_league() + league.visible = true + Analytics.track("league_open", {"leads": leads.size(), "filed": filed}) + _sync() + return true + + +func close_league() -> void: + if not league_open: + return + league_open = false + if ctx != null: + ctx.pop_modal(MODAL) + if league != null: + league.visible = false + _sync() + + +func _process(delta: float) -> void: + if ctx == null or ctx.sim == null: + return + _t -= delta + if _t <= 0.0: + _refresh() + if finder != null and finder.visible: + _aim() + + +## K, L and in photo mode the shutter come as events. K and L wait while a modal is open (the +## league itself closes on L or Esc); the shutter fires only on an assignment. +func _unhandled_input(event: InputEvent) -> void: + if ctx == null or ctx.player == null: + return + var used := false + if _shutter(event): + used = ctx.player.photo_mode and not story.is_empty() and _live() + if used: + snap() + elif event.is_echo(): + return + elif league_open: + used = event.is_action_pressed("press_league") or event.is_action_pressed("pause_menu") + if used: + close_league() + elif event.is_action_pressed("press_league"): + used = open_league() + elif event.is_action_pressed("press_story") and _may_press(): + press_story() + used = true + if used and is_inside_tree(): + get_viewport().set_input_as_handled() + + +## Whether event fires the shutter: the strike button going down, or its trigger crossing the dead +## zone (every motion event past it reads as pressed, so _trigger holds the trigger's state). +func _shutter(event: InputEvent) -> bool: + if event is InputEventJoypadMotion: + var held := event.is_action_pressed("act_strike") + var fired := held and not _trigger + if event.is_action("act_strike"): + _trigger = held + return fired + return event.is_action_pressed("act_strike") and not event.is_echo() + + +## Player input counts while it is enabled and, in a window, the mouse is captured (the click that +## captures it takes no photo). +func _live() -> bool: + var captured := Input.mouse_mode == Input.MOUSE_MODE_CAPTURED + return ctx.player.input_enabled and (captured or DisplayServer.get_name() == "headless") + + +## K counts on foot or at the wheel, with nothing modal open, not paused and not in photo mode. +func _may_press() -> bool: + if ctx.is_modal() or (ctx.mode != null and ctx.mode.paused): + return false + return not ctx.player.photo_mode + + +# ------------------------------------------------------------------ leads + + +func _on_mayor(mi: int, entry: Dictionary) -> void: + _add(DESK.mayor_lead(mi, entry)) + + +func _on_council(r: Dictionary) -> void: + _add(DESK.council_lead(r, _hall())) + + +func _on_chaos(d: Dictionary) -> void: + _add(DESK.chaos_lead(d)) + + +## A new lead, newest first; the first one ever comes with a tip. +func _add(lead: Dictionary) -> void: + if lead.is_empty() or ctx.sim == null: + return + leads.push_front(_dated(lead)) + if leads.size() > MAX_LEADS: + leads.resize(MAX_LEADS) + if not _tipped: + _tipped = true + _toast("A lead for the Gazette: %s. Press K to chase it." % lead["headline"], "info") + _refresh() + + +## Dates and places a lead; a council or Chaos Director lead takes its tile's district. +func _dated(lead: Dictionary) -> Dictionary: + var t: Vector2i = lead["tile"] + var i := D.idx(t.x, t.y) + if int(lead["district"]) < 0 and ctx.sim != null and i < ctx.sim.district.size(): + lead["district"] = ctx.sim.district[i] + lead["at"] = _now() + lead["place"] = _place(i) + return lead + + +## A reply face to face at the scene is the story's quote, unless the story already has a local +## voice and this one is not. +func _on_reply(resident_id: String, speaker: String, reply: String) -> void: + if story.is_empty() or not at_scene(): + return + var q := DESK.clip(reply) + if q.length() < QUOTE_MIN: + return + var local := false + var by := DESK.passerby(speaker) + if not resident_id.is_empty(): + by = speaker if not speaker.strip_edges().is_empty() else by + local = _lives_in(resident_id, int(story["district"])) + if bool(story["local"]) and not local: + return + var first := str(story["quote"]).is_empty() + story.merge({"quote": q, "by": by, "quote_id": resident_id, "local": local, "seen": true}, true) + if first: + _toast("Quote noted from %s. Press K to file, or take a photo first (P)." % by, "success") + _refresh() + + +func _lives_in(resident_id: String, district: int) -> bool: + var r: Dictionary = ctx.residents.find(resident_id) if ctx.residents != null else {} + if r.is_empty() or ctx.sim == null: + return false + var home := int(r.get("home", -1)) + return home >= 0 and home < ctx.sim.district.size() and ctx.sim.district[home] == district + + +## Every REFRESH: leads and the story go cold, the scene is reached, and the card, the league and +## what shows follow. +func _refresh() -> void: + _t = REFRESH + if ctx == null or ctx.sim == null: + return + var now := _now() + for k in range(leads.size() - 1, -1, -1): + if now - float(leads[k]["at"]) > LEAD_DAYS: + leads.remove_at(k) + if not story.is_empty() and now - float(story["at"]) > LEAD_DAYS: + _toast("The story went cold: %s." % story["headline"], "info") + _clear_story() + if not story.is_empty() and not bool(story["seen"]) and at_scene(): + story["seen"] = true + _toast("You are at the scene. Talk to someone here for a quote.", "info") + if card != null: + card.show_state(_card_state()) + if league_open: + _fill_league() + _sync() + + +## Drops the story on assignment and the waypoint set for it, while it is still the one on the map. +func _clear_story() -> void: + if not story.is_empty() and ctx.hud != null: + var wp: Variant = ctx.hud.waypoint() + var mine: Vector3 = story.get("wp", NO_POINT) + if wp is Vector3 and Vector2(wp.x - mine.x, wp.z - mine.z).length() < 0.01: + ctx.hud.clear_waypoint() + story = {} + + +func _drop_all() -> void: + close_league() + leads.clear() + _clear_story() + _refresh() + + +## The resident quoted remembers it. +func _credit(s: Dictionary) -> void: + var id := str(s.get("quote_id", "")) + if id.is_empty() or ctx.residents == null: + return + var r := ctx.residents.find(id) + if not r.is_empty(): + ctx.residents.remember(r, "was quoted in the Gazette: %s" % s["headline"]) + + +func _announce(out: Dictionary, was: String) -> void: + var bonus := DESK.bonus_words(out) + var extra := " (%s)" % bonus if not bonus.is_empty() else "" + _toast("Story filed: +%d%s. The Gazette runs it now." % [int(out["points"]), extra], "success") + var now := str(DESK.rank_of(points)["name"]) + if now != was: + _toast("Promoted: you are a %s now." % now.to_lower(), "success") + + +# ------------------------------------------------------------------ card, viewfinder, league + + +func _build_ui() -> void: + card = CARD.new() + card.setup() + card.visible = false + ctx.hud.slot("center").add_child(card) + finder = FINDER.new() + finder.setup() + ctx.hud.slot("overlay").add_child(finder) + league = LEAGUE.new() + league.setup() + league.visible = false + league.chase_requested.connect(_on_chase) + ctx.hud.slot("overlay").add_child(league) + + +## What the card shows: the story on assignment, else the newest lead; {} with neither. +func _card_state() -> Dictionary: + var lead: Dictionary = story + if lead.is_empty() and not leads.is_empty(): + lead = leads[0] + if lead.is_empty(): + return {} + var rank := DESK.rank_of(points) + var st := {"rank": "%s · %s" % [rank["name"], S.fmt(points)], "headline": lead["headline"]} + st["color"] = DESK.color_of(str(lead["agent"])) + st["where"] = _where(lead) + if story.is_empty(): + var more := leads.size() - 1 + st["kicker"] = "New lead" + st["hint"] = "Take lead" + (" · L: %d more" % more if more > 0 else "") + return st + var quoted := not str(story["quote"]).is_empty() + st["kicker"] = "Ready to file" if quoted else "On assignment" + st["quote"] = "“%s”" % story["quote"] if quoted else "" + st["steps"] = _steps() + st["hint"] = "File story" if quoted else "" + return st + + +## The story's checklist for the card: [text, done] pairs. Once quoted the scene row goes (a +## quote is only taken there), which keeps the card clear of the AI feed above it. +func _steps() -> Array: + var seen := bool(story["seen"]) + var quoted := not str(story["quote"]).is_empty() + var sharp := bool(story["sharp"]) + var out := [] if quoted else [["At the scene" if seen else "Get to the scene", seen]] + out.append(["Quote from %s" % story["by"] if quoted else "A quote from someone there", quoted]) + out.append(["Sharp photo" if sharp else "Photo (optional): P, then click", sharp]) + return out + + +## "214 Maple Street · 240 m · scoop 4:10": where, how far, and while it lasts the scoop clock. +func _where(lead: Dictionary) -> String: + var t: Vector2i = lead["tile"] + var me := ctx.player_position() + var d := Vector2(t.x - me.x, t.y - me.z).length() + var parts := PackedStringArray([str(lead["place"])]) + parts.append("at the scene" if d <= SCENE else S.distance(d)) + var left := SCOOP_DAYS - (_now() - float(lead["at"])) + if left > 0.0: + parts.append("scoop " + S.countdown(left * ctx.sim.day_seconds, ctx.sim.speed)) + return " · ".join(parts) + + +## The card shows while there is a lead or a story and nothing covers its corner (a modal, the +## phone); the viewfinder in photo mode on an assignment. Also runs on bus.modal_changed and when +## photo mode changes. +func _sync() -> void: + if card != null: + var phone := ctx.feature("phone") + var covered := ctx.is_modal() or (phone != null and bool(phone.get("open"))) + card.visible = not (story.is_empty() and leads.is_empty()) and not covered + if finder != null: + finder.visible = ctx.player != null and ctx.player.photo_mode and not story.is_empty() + if finder.visible: + _aim() + + +## The viewfinder follows the camera: green marks while the scene sits inside them. +func _aim() -> void: + var near := at_scene() + var framed := near and scene_framed() + var hint := "Click to take the shot" + if not near: + hint = "Too far from the scene for a press photo" + elif not framed: + hint = "Hold the scene inside the marks" + finder.show_state("Press photo · %s" % story["headline"], hint, framed) + + +func _fill_league() -> void: + var rank := DESK.rank_of(points) + var press := "%s · %s points · %d filed" % [rank["name"], S.fmt(points), filed] + if int(rank["next"]) > 0: + press += " · %s at %s" % [_next_rank(), S.fmt(int(rank["next"]))] + var open: Array[String] = [] + for l: Dictionary in leads: + open.append("%s · %s" % [l["headline"], l["place"]]) + var day := "Day %d" % ctx.sim.day + if ctx.sky != null: + day += " · " + ctx.sky.clock_text() + var desk := {"day": day, "press": press, "leads": open, "stories": stories} + desk["assignment"] = str(story.get("headline", "")) + league.show_league(DESK.standings(ctx.sim, ctx.residents), desk) + + +## The name of the rank after the player's. +func _next_rank() -> String: + return str(DESK.rank_of(int(DESK.rank_of(points)["next"]))["name"]) + + +func _on_chase(k: int) -> void: + close_league() + take_lead(k) + + +func _on_photo(on: bool) -> void: + _anchor = ctx.player.position if on else NO_POINT + _trigger = false + _sync() + + +func _on_modal(_active: bool) -> void: + _sync() + + +func _on_pause(p: bool) -> void: + if p: + close_league() + + +# ------------------------------------------------------------------ places + + +## Sim time in days (day 3 at noon is 3.5). +func _now() -> float: + return float(ctx.sim.day) + ctx.sim.day_progress() if ctx.sim != null else 0.0 + + +func _hall() -> Vector2i: + var i := ctx.sim.kind.find(D.HALL) if ctx.sim != null else -1 + return Vector2i(D.tx(i), D.ty(i)) if i >= 0 else HALL_AT + + +## The tile's place label ("214 Maple Street"), never coordinates. +func _place(i: int) -> String: + var label := ctx.directory.place_label(i) if ctx.directory != null else "" + return D.debrand(label) if not label.is_empty() else "the frontier" + + +## Where to stand to see the lead's tile: the overview feature's stand point (the far sidewalk +## across its street), else the tile itself. {"pos", "yaw"}. +func _stand(lead: Dictionary) -> Dictionary: + var t: Vector2i = lead["tile"] + var ov := ctx.feature("overview") + if ov != null: + return ov.call("stand_point", D.idx(t.x, t.y)) + return {"pos": Vector3(t.x, 0.0, t.y), "yaw": 0.0} + + +## The point a press photo of the lead must hold: SUBJECT_Y over the middle of its tile. +func _subject(lead: Dictionary) -> Vector3: + var t: Vector2i = lead["tile"] + var at := Vector3(t.x, 0.0, t.y) + var g := ctx.collision.ground_height(at) if ctx.collision != null else NAN + at.y = (0.0 if is_nan(g) else g) + SUBJECT_Y + return at + + +func _toast(t: String, kind: String) -> void: + if ctx.bus != null: + ctx.bus.toast.emit(t, kind) + + +## A filed story as the press card keeps it: {"text", "points", "bonus", "day"}. +static func _kept(s: Dictionary) -> Dictionary: + var out := {"text": str(s.get("text", "")), "points": int(s.get("points", 0))} + out["bonus"] = str(s.get("bonus", DESK.bonus_words(s))) + out["day"] = int(s.get("day", 0)) + return out + + +# ------------------------------------------------------------------ photo tour + + +## Three shots of a lead: its scene with the card on assignment, the press camera there with a +## sharp shot taken, and the league over the street at the spawn once the story is filed. +func photo_shots() -> Array: + _photo = _photo_lead() + if _photo.is_empty() or ctx.mode == null: + return [] + var stand := _stand(_photo) + var at := {"pos": stand["pos"], "yaw": float(stand["yaw"]), "pitch": -3.0, "hour": 10.5} + var shots := [] + for e: Array in [["reporter_story", "_photo_story"], ["reporter_photo", "_photo_camera"]]: + var s := at.duplicate() + s.merge({"name": e[0], "prepare": e[1], "restore": "_photo_restore"}) + shots.append(s) + var sp: Dictionary = ctx.mode.spawn_point() + var lg := {"name": "reporter_league", "pos": sp["pos"], "yaw": float(sp["yaw"]), "pitch": 0.0} + lg.merge({"hour": 10.5, "prepare": "_photo_league", "restore": "_photo_restore"}) + shots.append(lg) + return shots + + +func _photo_story() -> void: + _stage.call_deferred(false) + + +func _photo_camera() -> void: + _stage.call_deferred(true) + + +func _photo_league() -> void: + file_story() + open_league() + + +func _photo_restore() -> void: + close_league() + if ctx.player != null and ctx.player.photo_mode: + ctx.player.set_photo_mode(false) + + +## Once the tour has put the hero at the scene: the tour's lead on assignment with a passer-by's +## quote and, with camera, the press camera up and a sharp shot taken. +func _stage(camera: bool) -> void: + for _k in range(PHOTO_FRAMES): + await get_tree().process_frame + if _photo.is_empty() or ctx == null or ctx.player == null: + return + if str(story.get("headline", "")) != str(_photo["headline"]): + leads.push_front(_photo.duplicate()) + take_lead(0) + _on_reply("", PHOTO_SPEAKER, PHOTO_QUOTE) + if camera: + ctx.player.set_photo_mode(true) + _look_at(_subject(story)) + snap() + _refresh() + + +## Turns the photo camera to hold point in the middle of the view. +func _look_at(point: Vector3) -> void: + var p := ctx.player + var d := point - p.camera.global_position if p.camera != null else Vector3.ZERO + if d.length_squared() < 1e-8: + return + d = d.normalized() + p.cam_yaw = atan2(-d.x, -d.z) + p.cam_pitch = asin(clampf(d.y, -1.0, 1.0)) + p.update_view(0.0) + + +## The newest lead, else a council ruling at City Hall. +func _photo_lead() -> Dictionary: + if ctx == null or ctx.sim == null: + return {} + if not leads.is_empty(): + return leads[0].duplicate() + var lead := DESK.council_lead({"enacted": [D.ORDINANCES.keys()[0]]}, _hall()) + return _dated(lead) if not lead.is_empty() else {} diff --git a/scripts/fp/reporter/reporter_feature.gd.uid b/scripts/fp/reporter/reporter_feature.gd.uid new file mode 100644 index 00000000..7ca5f226 --- /dev/null +++ b/scripts/fp/reporter/reporter_feature.gd.uid @@ -0,0 +1 @@ +uid://dpm1xqf6c6575 diff --git a/scripts/fp/reporter/reporter_league.gd b/scripts/fp/reporter/reporter_league.gd new file mode 100644 index 00000000..9fc7608c --- /dev/null +++ b/scripts/fp/reporter/reporter_league.gd @@ -0,0 +1,247 @@ +extends Control +## The mayors' league (feature "reporter", L): the five AI mayors ranked on people, jobs, approval +## and funds (25 points each, ReporterDesk.standings), each with the badge of the model they run +## on, then the player's press card (rank, points, stories filed), the story on assignment, the +## open leads with a button to chase one, and the latest stories. The feature fills it through +## show_league(); nothing here reads the sim. + +## The Chase button of open lead k (0: the newest) was pressed. +signal chase_requested(lead: int) + +const S := preload("res://scripts/fp/ui/hud_style.gd") + +const WIDTH := 860.0 +## Table columns: [heading, width px, right-aligned]. With the 8 px gaps they fill the panel's +## inner width (WIDTH less its 20 px margins). +const COLS := [ + ["#", 26, false], + ["Mayor", 226, false], + ["District", 176, false], + ["People", 66, true], + ["Jobs", 58, true], + ["Approval", 76, true], + ["Funds", 76, true], + ["Score", 56, true], +] +const LISTED_STORIES := 3 + +var _sub: Label +var _table: GridContainer +var _press: Label +var _assignment: Label +var _leads: VBoxContainer +var _stories: VBoxContainer +var _lead_texts: Array[String] = [] + + +func setup() -> void: + name = "League" + mouse_filter = Control.MOUSE_FILTER_IGNORE + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + var panel := S.panel(Color(0.03, 0.04, 0.07, 0.92), S.RADIUS_PANEL, 20, 16) + panel.name = "Panel" + panel.mouse_filter = Control.MOUSE_FILTER_STOP + panel.anchor_left = 0.5 + panel.anchor_right = 0.5 + panel.anchor_top = 0.5 + panel.anchor_bottom = 0.5 + panel.offset_left = -WIDTH * 0.5 + panel.offset_right = WIDTH * 0.5 + panel.grow_horizontal = Control.GROW_DIRECTION_BOTH + panel.grow_vertical = Control.GROW_DIRECTION_BOTH + add_child(panel) + var col := VBoxContainer.new() + col.mouse_filter = Control.MOUSE_FILTER_IGNORE + col.add_theme_constant_override("separation", 8) + panel.add_child(col) + col.add_child(S.plain("Mayors' league", 26, S.WHITE, S.title())) + _sub = S.plain("", 14, S.TEXT_DIM, S.font()) + col.add_child(_sub) + _table = GridContainer.new() + _table.name = "Table" + _table.columns = COLS.size() + _table.mouse_filter = Control.MOUSE_FILTER_IGNORE + _table.add_theme_constant_override("h_separation", 8) + _table.add_theme_constant_override("v_separation", 6) + col.add_child(_table) + col.add_child(_rule()) + col.add_child(S.caps_label("Your press card", 12, S.AMBER, 0)) + _press = S.plain("", 16, S.TEXT, S.bold()) + col.add_child(_press) + _assignment = S.plain("", 14, S.TEXT, S.font()) + _assignment.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + col.add_child(_assignment) + _leads = _section(col, "Open leads") + _stories = _section(col, "Latest stories") + col.add_child(_footer()) + + +## rows: ReporterDesk.standings(), best first. desk: {"day" ("Day 6 · 14:20"), "press" (the +## player's rank and points), "assignment" (the story's headline, "" for none), "leads" (open +## leads' lines, newest first), "stories" ({"text", "points", "bonus"}, newest first)}. +func show_league(rows: Array[Dictionary], desk: Dictionary) -> void: + _sub.text = "%s · People, jobs, approval and funds: 25 points each" % desk.get("day", "") + _fill_table(rows) + _press.text = str(desk.get("press", "")) + var on := str(desk.get("assignment", "")) + _assignment.text = "On assignment: " + on if not on.is_empty() else "No story on assignment." + var lines: Array[String] = [] + for v: Variant in desk.get("leads", []): + lines.append(str(v)) + if lines != _lead_texts: + _lead_texts = lines + _fill_leads() + _fill_stories(desk.get("stories", [])) + + +## The table's rows as text, one line per mayor ("1 · Viktor Han · Harbor Heights · ... · 78"). +func table_lines() -> PackedStringArray: + var out := PackedStringArray() + var cells := _table.get_children() + for start in range(COLS.size(), cells.size(), COLS.size()): + var parts := PackedStringArray() + for c in range(COLS.size()): + parts.append(_cell_text(cells[start + c])) + out.append(" · ".join(parts)) + return out + + +## The Chase buttons, newest lead first. +func chase_buttons() -> Array[Button]: + var out: Array[Button] = [] + for row in _leads.get_children(): + for c in row.get_children(): + if c is Button: + out.append(c) + return out + + +func _fill_table(rows: Array[Dictionary]) -> void: + for c in _table.get_children(): + _table.remove_child(c) + c.queue_free() + for e: Array in COLS: + _add_cell(S.caps_label(str(e[0]), 11, S.TEXT_DIM, 0), e) + for k in range(rows.size()): + var r: Dictionary = rows[k] + var tint: Color = r["color"] + _add_cell(S.plain(str(k + 1), 16, tint, S.bold()), COLS[0]) + _add_cell(_who(r, tint), COLS[1]) + _add_cell(S.plain(str(r["district"]), 14, S.TEXT, S.font()), COLS[2]) + _add_cell(S.plain(S.fmt(int(r["pop"])), 14, S.TEXT, S.font()), COLS[3]) + _add_cell(S.plain(S.fmt(int(r["jobs"])), 14, S.TEXT, S.font()), COLS[4]) + var approval := "%.1f/10" % float(r["approval"]) + _add_cell(S.plain(approval, 14, S.TEXT, S.font()), COLS[5]) + var funds := S.plain(S.money(int(r["funds"])), 14, S.TEXT, S.font()) + if int(r["funds"]) < 0: + funds.add_theme_color_override("font_color", S.RED) + _add_cell(funds, COLS[6]) + _add_cell(S.plain(str(int(r["score"])), 17, S.WHITE, S.bold()), COLS[7]) + + +## The mayor's name in their colour and their model's badge. +func _who(r: Dictionary, tint: Color) -> HBoxContainer: + var h := HBoxContainer.new() + h.mouse_filter = Control.MOUSE_FILTER_IGNORE + h.add_theme_constant_override("separation", 8) + var name_l := S.plain(str(r["who"]), 15, tint, S.bold()) + name_l.name = "Who" + h.add_child(name_l) + if not str(r["model"]).is_empty(): + h.add_child(S.badge(str(r["model"]), tint, 10)) + return h + + +func _add_cell(c: Control, col: Array) -> void: + c.custom_minimum_size.x = float(col[1]) + if c is Label: + var l := c as Label + l.clip_text = true + l.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + if bool(col[2]): + l.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT + _table.add_child(c) + + +func _fill_leads() -> void: + for c in _leads.get_children(): + _leads.remove_child(c) + c.queue_free() + if _lead_texts.is_empty(): + var none := "None open. Every mayor's decision with a place brings one." + _leads.add_child(S.plain(none, 14, S.TEXT_DIM)) + return + for k in range(_lead_texts.size()): + var row := HBoxContainer.new() + row.mouse_filter = Control.MOUSE_FILTER_IGNORE + row.add_theme_constant_override("separation", 10) + var l := S.plain(_lead_texts[k], 14, S.TEXT, S.font()) + l.size_flags_horizontal = Control.SIZE_EXPAND_FILL + l.clip_text = true + l.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + row.add_child(l) + var b := S.pill_button("Chase", 13) + # L and Esc must reach _unhandled_input, never move GUI focus. + b.focus_mode = Control.FOCUS_NONE + b.pressed.connect(func() -> void: chase_requested.emit(k)) + row.add_child(b) + _leads.add_child(row) + + +func _fill_stories(list: Array) -> void: + for c in _stories.get_children(): + _stories.remove_child(c) + c.queue_free() + if list.is_empty(): + _stories.add_child(S.plain("Nothing filed yet. Press K to take a lead.", 14, S.TEXT_DIM)) + return + for s: Dictionary in list.slice(0, LISTED_STORIES): + var bonus := str(s.get("bonus", "")) + var head := "+%d" % int(s.get("points", 0)) + if not bonus.is_empty(): + head += " · " + bonus + _stories.add_child(S.plain(head, 12, S.GOOD, S.bold())) + var t := S.plain(str(s.get("text", "")), 14, S.TEXT, S.font()) + t.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + t.max_lines_visible = 2 + _stories.add_child(t) + + +func _section(col: VBoxContainer, title: String) -> VBoxContainer: + col.add_child(S.caps_label(title, 12, S.AMBER, 0)) + var v := VBoxContainer.new() + v.mouse_filter = Control.MOUSE_FILTER_IGNORE + v.add_theme_constant_override("separation", 4) + col.add_child(v) + return v + + +func _footer() -> HBoxContainer: + var bar := HBoxContainer.new() + bar.mouse_filter = Control.MOUSE_FILTER_IGNORE + bar.alignment = BoxContainer.ALIGNMENT_END + bar.add_theme_constant_override("separation", 6) + bar.add_child(S.key_cap("L", 12)) + bar.add_child(S.key_cap("Esc", 12)) + bar.add_child(S.plain("Close", 13, S.TEXT_DIM)) + return bar + + +static func _rule() -> ColorRect: + var r := ColorRect.new() + r.mouse_filter = Control.MOUSE_FILTER_IGNORE + r.color = Color(1, 1, 1, 0.1) + r.custom_minimum_size = Vector2(0, 1) + return r + + +## A table cell's text: a label's, or for the mayor cell the name and the badge. +static func _cell_text(c: Node) -> String: + if c is Label: + return (c as Label).text + var parts := PackedStringArray() + for n in c.get_children(): + var l := n as Label if n is Label else n.get_child(0) as Label + if l != null: + parts.append(l.text) + return " ".join(parts) diff --git a/scripts/fp/reporter/reporter_league.gd.uid b/scripts/fp/reporter/reporter_league.gd.uid new file mode 100644 index 00000000..534bb8d5 --- /dev/null +++ b/scripts/fp/reporter/reporter_league.gd.uid @@ -0,0 +1 @@ +uid://b63djkw5fywqv diff --git a/scripts/fp/reporter/reporter_viewfinder.gd b/scripts/fp/reporter/reporter_viewfinder.gd new file mode 100644 index 00000000..6f010dc9 --- /dev/null +++ b/scripts/fp/reporter/reporter_viewfinder.gd @@ -0,0 +1,115 @@ +extends Control +## The press camera's viewfinder (feature "reporter"), over the view in photo mode while a story is +## on assignment: corner marks round the middle of the view a press photo must hold the scene in +## (ReporterDesk.FRAME), green while it does, the story above the marks and what to do below them, +## the last shot's result under that, and a white flash for the shutter. The feature drives it +## through show_state(), note() and flash(). + +const S := preload("res://scripts/fp/ui/hud_style.gd") +const DESK := preload("res://scripts/fp/reporter/reporter_desk.gd") +## Corner mark arm length and line width, and the centre cross (px). +const ARM := 36.0 +const LINE := 3.0 +const CROSS := 8.0 +## How long the shutter flash and a shot's note last (s). +const FLASH_S := 0.3 +const NOTE_S := 2.5 +const IDLE := Color(1, 1, 1, 0.8) +const MISS := Color("#FFB547") + +## The scene sits inside the marks now. +var framed: bool = false +var _flash: float = 0.0 +var _note_t: float = 0.0 +var _title: Label +var _hint: Label +var _note: Label + + +func setup() -> void: + name = "Viewfinder" + mouse_filter = Control.MOUSE_FILTER_IGNORE + set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + var edge := (1.0 - DESK.FRAME) * 0.5 + _title = _place(S.caps_label("", 14, S.WHITE, 3), "Title", edge, -34.0) + _hint = _place(S.label("", 16, S.TEXT, S.bold()), "Hint", 1.0 - edge, 10.0) + _note = _place(S.label("", 18, S.GOOD, S.bold()), "Note", 1.0 - edge, 36.0) + _note.visible = false + visible = false + + +## title: the story; hint: what to do now; in_frame: the scene sits inside the marks. +func show_state(title: String, hint: String, in_frame: bool) -> void: + if _title.text != title: + _title.text = title + if _hint.text != hint: + _hint.text = hint + if framed != in_frame: + framed = in_frame + queue_redraw() + + +## Shows the result of a shot under the hint for NOTE_S seconds (green for a sharp one). +func note(t: String, sharp: bool) -> void: + _note.text = t + _note.add_theme_color_override("font_color", S.GOOD if sharp else MISS) + _note.visible = true + _note_t = NOTE_S + + +## The shot's result while it shows, else "". +func note_text() -> String: + return _note.text if _note.visible else "" + + +## The hint line ("Click to take the shot"). +func hint_text() -> String: + return _hint.text + + +## The shutter: the view flashes white and fades over FLASH_S. +func flash() -> void: + _flash = FLASH_S + queue_redraw() + + +func _process(delta: float) -> void: + if _flash > 0.0: + _flash = maxf(0.0, _flash - delta) + queue_redraw() + if _note_t > 0.0: + _note_t -= delta + _note.visible = _note_t > 0.0 + + +func _draw() -> void: + var m := size * (1.0 - DESK.FRAME) * 0.5 + var r := Rect2(m, size - m * 2.0) + var col := S.GOOD if framed else IDLE + var corners := [ + r.position, Vector2(r.end.x, r.position.y), r.end, Vector2(r.position.x, r.end.y) + ] + for c: Vector2 in corners: + var dx := ARM if c.x < size.x * 0.5 else -ARM + var dy := ARM if c.y < size.y * 0.5 else -ARM + var arms := PackedVector2Array([c + Vector2(0.0, dy), c, c + Vector2(dx, 0.0)]) + draw_polyline(arms, col, LINE, true) + var mid := size * 0.5 + draw_line(mid - Vector2(CROSS, 0.0), mid + Vector2(CROSS, 0.0), col, 1.5, true) + draw_line(mid - Vector2(0.0, CROSS), mid + Vector2(0.0, CROSS), col, 1.5, true) + if _flash > 0.0: + draw_rect(Rect2(Vector2.ZERO, size), Color(1, 1, 1, 0.85 * _flash / FLASH_S)) + + +## A centred line of text across the view, its top offset px from anchor (a fraction of the height). +func _place(l: Label, node_name: String, anchor: float, offset: float) -> Label: + l.name = node_name + l.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + l.anchor_left = 0.0 + l.anchor_right = 1.0 + l.anchor_top = anchor + l.anchor_bottom = anchor + l.offset_top = offset + l.offset_bottom = offset + 24.0 + add_child(l) + return l diff --git a/scripts/fp/reporter/reporter_viewfinder.gd.uid b/scripts/fp/reporter/reporter_viewfinder.gd.uid new file mode 100644 index 00000000..77154247 --- /dev/null +++ b/scripts/fp/reporter/reporter_viewfinder.gd.uid @@ -0,0 +1 @@ +uid://cq2me6s8t50fq diff --git a/scripts/fp/ui/pause_menu.gd b/scripts/fp/ui/pause_menu.gd index 7164d256..4a64d613 100644 --- a/scripts/fp/ui/pause_menu.gd +++ b/scripts/fp/ui/pause_menu.gd @@ -57,6 +57,7 @@ const KEYS := [ ["M", "Map"], ["Tab", "City overview"], ["V", "Camera view"], ["Q", "Shoulder"], ["E", "Interact"], ["F", "Get in / out of a car"], ["B", "Build phone"], ["Click", "Shove"], ["Right-click", "Guard (click to punch)"], ["1-5", "Emotes"], ["Enter", "Chat"], ["J", "AI activity"], ["G", "Route to AI decision"], + ["K", "Take lead / file story"], ["L", "Mayors' league"], ["[ ]", "Game speed"], ["T", "Skip to morning"], ["P", "Photo mode"], ["F1", "Hide HUD"], ["F5", "Quick save"], ["F9", "Quick load"], ] diff --git a/tools/tests/fp_actions_test.gd b/tools/tests/fp_actions_test.gd index 75c65b0c..760162ee 100644 --- a/tools/tests/fp_actions_test.gd +++ b/tools/tests/fp_actions_test.gd @@ -336,31 +336,31 @@ func _check_held_input() -> void: p.input_enabled = true _step(DT) var n0 := int(f.get("swings")) - _send(_action("act_strike")) + _send(K.action_event("act_strike")) K.check(fails, int(f.get("swings")) == n0 + 1, "a strike press swings") _step(0.7) K.check(fails, int(f.get("swings")) == n0 + 1, "once") for v: float in [0.6, 1.0, 0.8]: - _send(_trigger(v)) + _send(K.trigger_event(v)) _step(0.7) var held := int(f.get("swings")) - n0 K.check(fails, held == 2, "a trigger held down swings once (%d)" % (held - 1)) - _send(_trigger(0.1)) - _send(_trigger(0.9)) + _send(K.trigger_event(0.1)) + _send(K.trigger_event(0.9)) K.check(fails, int(f.get("swings")) == n0 + 3, "pulled again, it swings again") _step(0.7) ctx.push_modal(&"test") - _send(_action("act_strike")) + _send(K.action_event("act_strike")) ctx.pop_modal(&"test") p.input_enabled = false - _send(_action("act_strike")) + _send(K.action_event("act_strike")) p.input_enabled = true var taken := int(f.get("swings")) - n0 - 3 K.check(fails, taken == 0, "no press is taken under a modal or with input off (%d)" % taken) acts.clear() - _send(_key_event(KEY_2, true)) + _send(K.key_event(KEY_2, true)) K.check(fails, acts.is_empty(), "a held key's echo does nothing") - _send(_key_event(KEY_2)) + _send(K.key_event(KEY_2)) K.check(fails, acts == ["yes"], "key 2 nods (%s)" % str(acts)) f.call("cancel") _step(1.1) @@ -456,28 +456,6 @@ func _send(e: InputEvent) -> void: f.call("_unhandled_input", e) -func _action(a: String) -> InputEventAction: - var e := InputEventAction.new() - e.action = a - e.pressed = true - return e - - -func _trigger(v: float) -> InputEventJoypadMotion: - var e := InputEventJoypadMotion.new() - e.axis = JOY_AXIS_TRIGGER_RIGHT - e.axis_value = v - return e - - -func _key_event(k: Key, echo: bool = false) -> InputEventKey: - var e := InputEventKey.new() - e.physical_keycode = k - e.pressed = true - e.echo = echo - return e - - func _pad(action: String) -> String: for e: InputEvent in InputMap.action_get_events(action): if e is InputEventJoypadMotion: diff --git a/tools/tests/fp_core_test.gd b/tools/tests/fp_core_test.gd index 06f60c10..9e799b3d 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() == 16, "all 16 registry entries load as FpFeatures with matching unique ids") + K.check(fails, ok and ids.size() == 17, "all 17 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 {} diff --git a/tools/tests/fp_kit.gd b/tools/tests/fp_kit.gd index 21295d24..aefffbe8 100644 --- a/tools/tests/fp_kit.gd +++ b/tools/tests/fp_kit.gd @@ -282,6 +282,31 @@ static func check(fails: Array, ok: bool, what: String) -> void: fails.append(what) +## Input events for a feature's _unhandled_input: action a pressed. +static func action_event(a: String) -> InputEventAction: + var e := InputEventAction.new() + e.action = a + e.pressed = true + return e + + +## The right trigger at axis value v. +static func trigger_event(v: float) -> InputEventJoypadMotion: + var e := InputEventJoypadMotion.new() + e.axis = JOY_AXIS_TRIGGER_RIGHT + e.axis_value = v + return e + + +## Physical key k pressed (echo: a key repeat). +static func key_event(k: Key, echo: bool = false) -> InputEventKey: + var e := InputEventKey.new() + e.physical_keycode = k + e.pressed = true + e.echo = echo + return e + + static func _has_c_lot(sim: CitySim) -> bool: for i in sim.active: if sim.kind[i] == D.ZONE_C and sim.level[i] >= 1: diff --git a/tools/tests/fp_reporter_test.gd b/tools/tests/fp_reporter_test.gd new file mode 100644 index 00000000..51046997 --- /dev/null +++ b/tools/tests/fp_reporter_test.gd @@ -0,0 +1,944 @@ +extends SceneTree +## The Gazette's stringer (integration feature "reporter"; docs/fp/ARCHITECTURE.md, Reporter): the +## desk's rules (which AI decisions make a lead and how they read, quotes and bylines, the story's +## text, points and press ranks, what a photo frames, the mayors' league); leads from a mayor's +## turn, a council ruling and the Chaos Director; the card; taking a lead and its waypoint; the +## scene; quotes from the "Ask someone here" card and from a resident in the talk dialog (a local +## voice); the press camera (viewfinder, shutter, trigger); filing (points, ranks, the Gazette's +## chat line, the resident's memory, the player's own waypoint); leads and stories going cold; K +## and L; the league (table, press card, Chase); the save round trip; the photo tour's staging; +## teardown. Third person on a grown city with the HUD widgets and the chat. The sim's clock is +## stopped: the feature's own runs only through the test's _process calls. + +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 DESK := preload("res://scripts/fp/reporter/reporter_desk.gd") +const REPORTER := preload("res://scripts/fp/reporter/reporter_feature.gd") +const DT := 1.0 / 60.0 +const QUOTE := "It is about time somebody did something about this corner." +const SHARP := "Sharp shot. It runs with the story." + +var fails: Array[String] = [] +var ctx: FpContext +var f: REPORTER +var chat: FpFeature +var talk: Control +## Every toast's text and every story_filed dict. +var toasts: Array[String] = [] +var filed: Array[Dictionary] = [] +## The fixture: a resident who lives in a mayor's district, their home tile and that district. +var neighbour: Dictionary = {} +var home := Vector2i.ZERO +var district: int = -1 +var _coords := RegEx.create_from_string("\\(\\s*-?\\d+\\s*,\\s*-?\\d+\\s*\\)") + + +func _init() -> void: + await process_frame + ctx = K.make_ctx(get_root(), {"grow": true, "camera_mode": "third"}) + ctx.player.set_process(false) + ctx.sim.set_process(false) + K.make_feature(ctx, "hud_widgets") + chat = K.make_feature(ctx, "chat") + f = K.make_feature(ctx, "reporter") as REPORTER + K.check(fails, f != null and chat != null, "the reporter and chat features are registered") + if f == null or chat == null: + K.finish(self, "FP_REPORTER_TEST", fails) + return + f.set_process(false) + talk = chat.get("talk") + ctx.bus.toast.connect(func(t: String, _kind: String) -> void: toasts.append(t)) + ctx.bus.story_filed.connect(func(s: Dictionary) -> void: filed.append(s)) + _check_inputs() + _check_desk_leads() + _check_desk_words() + _check_desk_scores() + _check_standings() + _pick_neighbour() + if not neighbour.is_empty(): + _check_leads() + _check_card() + _check_take() + _check_scene() + _check_quotes() + _check_photo() + _check_shutter_input() + _check_file() + _check_cold() + _check_keys() + _check_league_keys() + _check_league() + _check_save() + await _check_photo_tour() + _check_teardown() + K.finish(self, "FP_REPORTER_TEST", fails) + + +# ------------------------------------------------------------------ the desk + + +func _check_inputs() -> void: + var st := f.self_test() + var detail := str(st.get("detail", "")) + K.check(fails, bool(st.get("ok", false)), "self_test: inputs and UI in place (%s)" % detail) + K.check(fails, detail == "0 leads, 0 filed, Stringer", "a stringer with nothing on to start") + var keys := [str(_keys("press_story")), str(_keys("press_league"))] + var keys_ok := keys == [str([KEY_K]), str([KEY_L])] + K.check(fails, keys_ok, "K takes a lead and files it, L opens the league (%s)" % str(keys)) + var clash: Array[String] = [] + for a: StringName in InputMap.get_actions(): + if str(a).begins_with("ui_") or REPORTER.INPUTS.has(str(a)): + continue + var ks := _keys(a) + if ks.has(KEY_K) or ks.has(KEY_L): + clash.append(str(a)) + K.check(fails, clash.is_empty(), "no other action takes K or L (%s)" % str(clash)) + + +func _check_desk_leads() -> void: + var at := Vector2i(3, 7) + var police := {"type": "build", "building": "police", "x": 3, "y": 7} + var hospital := {"type": "build", "building": "hospital", "x": 3, "y": 7} + var turn := { + "focus": at, "actions": [{"ok": false, "action": hospital}, {"ok": true, "action": police}] + } + var lead := DESK.mayor_lead(1, turn) + var want := { + "agent": "1", "headline": "Viktor Han ordered a Police Station", "tile": at, "district": 1 + } + K.check(fails, lead == want, "a mayor's lead: the first order carried out (%s)" % str(lead)) + var none := [ + DESK.mayor_lead(1, {"actions": turn["actions"]}), + DESK.mayor_lead(7, turn), + DESK.mayor_lead(1, {"focus": Vector2i(500, 500)}), + ] + K.check(fails, none == [{}, {}, {}], "none without a focus, a real mayor or a place on the map") + var tax := {"ok": true, "action": {"type": "tax", "rate": 9}} + var vague := str(DESK.mayor_lead(0, {"focus": at, "actions": [tax]}).get("headline", "")) + K.check( + fails, vague == "Ada Okafor made a decision here", "an order without a place (%s)" % vague + ) + var hall := Vector2i(15, 14) + var passed := DESK.council_lead({"enacted": ["recycling"]}, hall) + var ruling := {"agent": "council", "headline": "The council passed the Recycling Program"} + ruling.merge({"tile": hall, "district": -1}) + K.check(fails, passed == ruling, "a council ruling is a lead at City Hall (%s)" % str(passed)) + var repeal := DESK.council_lead({"enacted": ["bogus"], "repealed": ["curfew"]}, hall) + var rep := str(repeal.get("headline", "")) + K.check(fails, rep == "The council repealed the Youth Curfew", "a repeal too (%s)" % rep) + var idle := DESK.council_lead({"enacted": [], "repealed": []}, hall) + K.check(fails, idle.is_empty(), "a ruling that changes nothing is none") + var fire := DESK.chaos_lead({"trigger": true, "type": "fire", "x": 4, "y": 9}) + var strike := {"agent": "chaos", "headline": "Building Fire reported", "tile": Vector2i(4, 9)} + strike["district"] = -1 + K.check( + fails, fire == strike, "a Chaos Director strike is a lead where it struck (%s)" % str(fire) + ) + var calm := DESK.chaos_lead({"trigger": false, "type": "fire", "x": 4, "y": 9}) + var nowhere := DESK.chaos_lead({"trigger": true, "type": "smog"}) + K.check( + fails, calm.is_empty() and nowhere.is_empty(), "a calm day or a strike with no place: none" + ) + + +func _check_desk_words() -> void: + var orders := [ + {"type": "build", "building": "airport"}, + {"type": "zone", "zone": "c"}, + {"type": "zone", "zone": "Industrial"}, + {"type": "road"}, + {"type": "bulldoze"}, + {"type": "emergency"}, + {"type": "annex"}, + {"type": "ordinance"}, + ] + var deeds := orders.map(func(a: Dictionary) -> String: return DESK.deed(a)) + var want := [ + "ordered an Airport", + "zoned new shops", + "zoned new industry", + "laid a new road", + "cleared a block", + "sent in emergency crews", + "annexed new land", + "made a decision here", + ] + K.check(fails, deeds == want, "each kind of order reads plainly (%s)" % str(deeds)) + var agents := ["1", "chaos", "council", "9"] + var colors := agents.map(func(a: String) -> Color: return DESK.color_of(a)) + var hexes := [Color("#00F0FF"), Color("#FF7A59"), Color("#C9B6FF"), Color("#FFD27F")] + K.check(fails, colors == hexes, "a lead wears the deciding agent's colour, else the Gazette's") + var long := DESK.clip("We waited years for this and nobody asked us first. ".repeat(5)) + var cut := long.length() <= DESK.QUOTE_CHARS and long.ends_with("…") and not long.contains(" …") + K.check( + fails, cut, "a long reply is cut at a word, with an ellipsis (%d chars)" % long.length() + ) + var clean := DESK.clip(" Ask DeepSeek-V4 about it.\nReally. ") + K.check( + fails, clean == "Ask Viktor Han about it. Really.", "de-branded, on one line (%s)" % clean + ) + var speakers := ["Shopkeeper", "officer", "Police officer", "Mara Ellis", " "] + var bylines := speakers.map(func(s: String) -> String: return DESK.passerby(s)) + var want_by := [ + "a shopkeeper", "an officer", "a police officer", "Mara Ellis", "someone nearby" + ] + K.check(fails, bylines == want_by, "a passer-by's byline (%s)" % str(bylines)) + var s := {"place": "214 Maple Street", "headline": "Viktor Han ordered a Police Station"} + var texts := [DESK.story_text(s)] + s.merge({"quote": "About time.", "by": "Ana Ruiz"}) + texts.append(DESK.story_text(s)) + s.merge({"quote": "What a mess!", "by": ""}, true) + texts.append(DESK.story_text(s)) + var lede := "From 214 Maple Street: Viktor Han ordered a Police Station." + var runs := [ + lede, lede + " “About time,” says Ana Ruiz.", lede + " “What a mess!” says someone nearby." + ] + K.check(fails, texts == runs, "the story as the Gazette runs it (%s)" % str(texts)) + + +func _check_desk_scores() -> void: + var pts := [ + DESK.points_for(false, false, false), + DESK.points_for(true, false, false), + DESK.points_for(false, true, true), + DESK.points_for(true, true, true), + ] + var pts_msg := "100 a story, +50 a scoop, +25 a local voice, +25 a sharp shot (%s)" + K.check(fails, pts == [100, 150, 150, 200], pts_msg % str(pts)) + var words := [ + DESK.bonus_words({"scoop": true, "sharp": true}), DESK.bonus_words({"local": false}) + ] + K.check(fails, words == ["scoop, sharp shot", ""], "the bonuses in words (%s)" % str(words)) + var ranks: Array[String] = [] + for p: int in [0, 299, 300, 999, 1000, 2500, 9000]: + var r := DESK.rank_of(p) + ranks.append("%s/%d" % [r["name"], int(r["next"])]) + var want := [ + "Stringer/300", + "Stringer/300", + "Reporter/1000", + "Reporter/1000", + "Senior reporter/2500", + "Star reporter/-1", + "Star reporter/-1", + ] + K.check( + fails, str(ranks) == str(want), "press ranks and the points to the next (%s)" % str(ranks) + ) + var view := Vector2(1000, 600) + var inside := [DESK.framed(Vector2(500, 300), view), DESK.framed(Vector2(200, 120), view)] + var outside := [DESK.framed(Vector2(100, 300), view), DESK.framed(Vector2(500, 560), view)] + var framing := inside == [true, true] and outside == [false, false] + K.check(fails, framing, "a photo frames what sits in the middle 65% of the view") + + +func _check_standings() -> void: + var rows := DESK.standings(ctx.sim, ctx.residents) + var n := rows.size() + K.check(fails, n == D.MAYORS.size(), "the league ranks all five mayors (%d)" % n) + var named := 0 + var scored := 0 + var ordered := 0 + var biggest: Dictionary = {} + for k in range(n): + var r: Dictionary = rows[k] + var mi := int(r["mi"]) + var who := [str(r["who"]), str(r["model"]), str(r["district"])] + var real := [D.mayor_display(mi), str(D.MAYORS[mi]["name"]), str(D.MAYORS[mi]["district"])] + named += int(who == real) + scored += int(_within(float(r["score"]), 100.0) and _within(float(r["approval"]), 10.0)) + ordered += int(k == 0 or int(rows[k - 1]["score"]) >= int(r["score"])) + if biggest.is_empty() or int(r["pop"]) > int(biggest["pop"]): + biggest = r + K.check(fails, named == n, "each row names the mayor, the model they run on and their district") + K.check(fails, scored == n, "approval out of 10, a score out of 100") + var scores := rows.map(func(r: Dictionary) -> int: return int(r["score"])) + K.check(fails, ordered == n, "best first (%s)" % str(scores)) + var top := biggest.is_empty() or int(biggest["pop"]) == 0 or int(biggest["score"]) >= 25 + K.check( + fails, top, "the most people earn the full 25 for people (%s)" % str(biggest.get("score")) + ) + K.check(fails, DESK.standings(null, null).is_empty(), "no city, no league") + + +## The fixture: a resident who lives in one of the mayors' districts. +func _pick_neighbour() -> void: + for r: Dictionary in ctx.residents.people: + var h := int(r["home"]) + var d := int(ctx.sim.district[h]) + if d >= 0 and d < D.MAYORS.size(): + neighbour = r + home = Vector2i(D.tx(h), D.ty(h)) + district = d + break + var msg := "fixture: a resident lives in a mayor's district (%d people)" + K.check(fails, not neighbour.is_empty(), msg % ctx.residents.people.size()) + + +# ------------------------------------------------------------------ leads and the story + + +func _check_leads() -> void: + K.check(fails, f.leads.is_empty() and not f.card.visible, "no lead yet, no card") + toasts.clear() + f.press_story() + var none := f.story.is_empty() and _count(toasts, "No leads yet") == 1 + K.check(fails, none, "K with no lead says how leads come (%s)" % str(toasts)) + toasts.clear() + ctx.brain.council_ruled.emit({"enacted": ["recycling"], "repealed": [], "statement": ""}) + var hall_i := ctx.sim.kind.find(D.HALL) + var hall := Vector2i(D.tx(hall_i), D.ty(hall_i)) if hall_i >= 0 else REPORTER.HALL_AT + var first: Dictionary = f.leads[0] if f.leads.size() == 1 else {} + var at_hall: bool = first.get("tile") == hall and str(first.get("agent", "")) == "council" + K.check( + fails, at_hall, "a council ruling brings a lead at City Hall (%s)" % str(first.get("tile")) + ) + var hall_d := int(ctx.sim.district[D.idx(hall.x, hall.y)]) + var in_d := int(first.get("district", -9)) == hall_d + K.check(fails, in_d, "in the district City Hall stands in (%d)" % hall_d) + var tip := _count(toasts, "A lead for the Gazette") == 1 and _count(toasts, "Press K") == 1 + K.check(fails, tip, "the first lead comes with a tip: press K (%s)" % str(toasts)) + var c := home + Vector2i(2, 0) + ctx.brain.chaos_decided.emit({"trigger": true, "type": "fire", "x": c.x, "y": c.y}) + var chaos_d := int(ctx.sim.district[D.idx(c.x, c.y)]) + var struck := int(f.leads[0]["district"]) == chaos_d + K.check(fails, struck, "a strike takes its tile's district (%d)" % chaos_d) + var n := f.leads.size() + ctx.brain.chaos_decided.emit({"trigger": false}) + ctx.brain.council_ruled.emit({"enacted": [], "repealed": []}) + ctx.brain.mayor_decided.emit(district, {"focus": D.NO_TILE, "actions": []}) + var same := f.leads.size() == n + K.check(fails, same, "a calm day, an empty ruling or a turn without a place bring none") + var o1 := (district + 1) % D.MAYORS.size() + var o2 := (district + 2) % D.MAYORS.size() + _mayor(o1, home + Vector2i(0, 2), {"type": "zone", "zone": "C", "x": home.x, "y": home.y + 2}) + _mayor(o2, home + Vector2i(-2, 0), {"type": "road", "x": home.x - 2, "y": home.y}) + _mayor(district, home, {"type": "build", "building": "police", "x": home.x, "y": home.y}) + var heads := f.leads.map(func(l: Dictionary) -> String: return str(l["headline"])) + var want := [ + "%s ordered a Police Station" % D.mayor_display(district), + "%s laid a new road" % D.mayor_display(o2), + "%s zoned new shops" % D.mayor_display(o1), + "Building Fire reported", + ] + K.check(fails, heads == want, "at most four open leads, newest first (%s)" % str(heads)) + var lead: Dictionary = f.leads[0] + var place := D.debrand(ctx.directory.place_label(D.idx(home.x, home.y))) + var named := str(lead["place"]) == place and _coords.search(place) == null + K.check(fails, named, "a lead names its place, never coordinates (%s)" % place) + var dated := int(lead["district"]) == district and absf(float(lead["at"]) - _now()) < 1e-6 + K.check(fails, dated, "with the deciding mayor's district and when it happened") + K.check(fails, _count(toasts, "A lead for the Gazette") == 1, "the tip comes once") + + +func _check_card() -> void: + K.check(fails, f.card.visible, "a lead brings up the card") + var t := f.card.card_text() + var parts := ["New lead", "Stringer · 0", str(f.leads[0]["headline"]), "scoop "] + parts.append("K Take lead · L: 3 more") + K.check( + fails, _has_all(t, parts), "the card: the newest lead, the scoop clock, the key (%s)" % t + ) + ctx.push_modal(&"test") + var covered := not f.card.visible + ctx.pop_modal(&"test") + K.check(fails, covered and f.card.visible, "a modal covers it, then it comes back") + + +func _check_take() -> void: + ctx.player.teleport(_away(home, 8), 0.0) + toasts.clear() + var bad := not f.take_lead(9) and f.story.is_empty() and toasts.is_empty() + K.check(fails, bad, "no such lead: nothing taken, nothing said (%s)" % str(toasts)) + var lead: Dictionary = f.leads[0].duplicate() + var taken := f.take_lead(0) + var on := str(f.story.get("headline", "")) == str(lead["headline"]) + K.check( + fails, taken and on and f.leads.size() == 3, "take_lead(0) puts the newest on assignment" + ) + var fresh := not bool(f.story["seen"]) and str(f.story["quote"]).is_empty() + K.check(fails, fresh, "far from the scene, with no quote yet") + var wp: Variant = ctx.hud.waypoint() + var to_tile := wp is Vector3 and _flat(wp, Vector3(home.x, 0.0, home.y)) < 0.01 + K.check(fails, to_tile, "a waypoint leads to the scene (%s)" % str(wp)) + var told := "On assignment: %s. Get a quote at %s." % [lead["headline"], lead["place"]] + K.check(fails, _count(toasts, told) == 1, "a toast says what and where (%s)" % str(toasts)) + var t := f.card.card_text() + var steps := ["On assignment", "[ ] Get to the scene", "[ ] A quote from someone there"] + steps.append("[ ] Photo (optional): P, then click") + var card_ok := _has_all(t, steps) and not t.contains("· K ") + K.check(fails, card_ok, "the card: on assignment, what it needs, no key yet (%s)" % t) + toasts.clear() + f.press_story() + var go := _count(toasts, "get to %s for a quote" % lead["place"]) == 1 + K.check(fails, f.filed == 0 and go, "K far from the scene says where to go (%s)" % str(toasts)) + K.check(fails, f.file_story().is_empty(), "nothing to file without a quote") + + +func _check_scene() -> void: + toasts.clear() + ctx.bus.talk_replied.emit("", "Shopkeeper", QUOTE) + K.check(fails, str(f.story["quote"]).is_empty(), "a reply far from the scene is no quote") + _stand_by(home) + f._process(REPORTER.REFRESH + 0.01) + K.check( + fails, bool(f.story["seen"]) and f.at_scene(), "1.5 tiles from it the hero is at the scene" + ) + K.check(fails, _count(toasts, "You are at the scene") == 1, "and is told so (%s)" % str(toasts)) + var t := f.card.card_text() + K.check( + fails, _has_all(t, ["[x] At the scene", "at the scene"]), "the card ticks it off (%s)" % t + ) + toasts.clear() + f.press_story() + var ask := _count(toasts, "talk to someone here for a quote") == 1 + K.check( + fails, f.filed == 0 and ask, "K there without a quote: talk to someone (%s)" % str(toasts) + ) + + +func _check_quotes() -> void: + var place := str(f.story["place"]) + toasts.clear() + ctx.bus.talk_replied.emit("", "Shopkeeper", "Hm.") + K.check(fails, str(f.story["quote"]).is_empty(), "a reply too short to quote is none") + talk.call("open_ask", place) + talk.call("set_ask_answer", "Shopkeeper", place, QUOTE) + talk.call("end") + var asked := [str(f.story["quote"]), str(f.story["by"]), bool(f.story["local"])] + var asked_ok := asked == [QUOTE, "a shopkeeper", false] + K.check(fails, asked_ok, "the ask card's answer is the quote (%s)" % str(asked)) + K.check(fails, _count(toasts, "Quote noted from a shopkeeper") == 1, "a toast notes it") + ctx.bus.talk_replied.emit("r_nobody", "Sam Doe", "Nobody round here saw this one coming.") + var other := [str(f.story["by"]), bool(f.story["local"]), _count(toasts, "Quote noted")] + var other_ok := other == ["Sam Doe", false, 1] + K.check(fails, other_ok, "someone else's newer reply replaces it, quietly (%s)" % str(other)) + var id := str(neighbour["id"]) + ctx.bus.talk_requested.emit(id) + talk.call("say", "What do you think of the mayor?") + if bool(talk.get("waiting")): + _pump(9.0) + var reply := str(talk.call("shown_text")) + talk.call("end") + var by := str(f.story["by"]) + var voice := [ + str(f.story["quote_id"]) == id, bool(f.story["local"]), by == str(neighbour["name"]) + ] + var voice_msg := "the neighbour's reply in the talk dialog is a local voice (%s: %s)" + K.check(fails, voice == [true, true, true], voice_msg % [by, f.story["quote"]]) + K.check(fails, str(f.story["quote"]) == DESK.clip(reply), "quoted as said (%s)" % reply) + ctx.bus.talk_replied.emit("", "Officer", "Another passer-by with a view on all of this.") + var kept := str(f.story["quote_id"]) == id + K.check(fails, kept, "a passer-by after a local voice does not replace it") + var t := f.card.card_text() + var ready := [ + "Ready to file", "“%s”" % f.story["quote"], "[x] Quote from " + by, "K File story" + ] + var ready_ok := _has_all(t, ready) and not t.contains("At the scene") + K.check(fails, ready_ok, "the card: ready to file, the quote, K files, no scene row (%s)" % t) + + +func _check_photo() -> void: + var p := ctx.player + var finder := f.finder + K.check( + fails, not f.snap() and not finder.visible, "no shutter or viewfinder out of photo mode" + ) + p.set_photo_mode(true) + K.check(fails, finder.visible, "photo mode on an assignment brings up the viewfinder") + _aim_at(_subject(home)) + f._process(DT) + var framed := [f.scene_framed(), finder.framed, finder.hint_text()] + var framed_msg := "the scene inside the marks turns them green (%s)" + K.check(fails, framed == [true, true, "Click to take the shot"], framed_msg % str(framed)) + var sharp := f.snap() + K.check(fails, sharp and bool(f.story["sharp"]), "the shutter takes a sharp shot") + K.check(fails, finder.note_text() == SHARP, "and says so (%s)" % finder.note_text()) + p.cam_yaw = wrapf(p.cam_yaw + PI, -PI, PI) + p.update_view(0.0) + f._process(DT) + var away := [f.scene_framed(), finder.framed, finder.hint_text()] + var away_msg := "looking away the marks stay white (%s)" + K.check(fails, away == [false, false, "Hold the scene inside the marks"], away_msg % str(away)) + var missed := not f.snap() and bool(f.story["sharp"]) + var miss_note := finder.note_text() == "Missed it: hold the scene inside the marks." + K.check(fails, missed and miss_note, "a miss keeps the sharp shot (%s)" % finder.note_text()) + p.teleport(_away(home, 8), 0.0) + K.check(fails, not p.photo_mode and not finder.visible, "a teleport ends photo mode") + p.set_photo_mode(true) + var far := not f.snap() and finder.note_text() == "Too far from the scene for a press photo." + f._process(DT) + var far_hint := finder.hint_text() == "Too far from the scene for a press photo" + K.check( + fails, far and far_hint, "too far from the scene, no press photo (%s)" % finder.hint_text() + ) + p.set_photo_mode(false) + _stand_by(home) + + +func _check_shutter_input() -> void: + var p := ctx.player + p.set_photo_mode(true) + _aim_at(_subject(home)) + f.story["sharp"] = false + _send(K.action_event("act_strike")) + var input_off := not bool(f.story["sharp"]) + p.input_enabled = true + _send(K.action_event("act_strike")) + var clicked := bool(f.story["sharp"]) and f.finder.note_text() == SHARP + _send(K.action_event("act_strike")) + var again := f.finder.note_text() == "Another sharp one." + var click_msg := "a click takes the shot (not with input off) and a second says so (%s)" + var clicks := [input_off, clicked, again] + K.check(fails, clicks == [true, true, true], click_msg % str(clicks)) + var shots := 0 + for v: float in [0.9, 1.0, 0.1, 0.8]: + f.story["sharp"] = false + _send(K.trigger_event(v)) + shots += int(bool(f.story["sharp"])) + K.check(fails, shots == 2, "the right trigger fires once per pull (%d)" % shots) + p.set_photo_mode(false) + f.story["sharp"] = false + _send(K.action_event("act_strike")) + K.check(fails, not bool(f.story["sharp"]), "out of photo mode a click takes nothing") + f.story["sharp"] = true + p.input_enabled = false + + +func _check_file() -> void: + var s: Dictionary = f.story.duplicate() + var mine := _away(home, 5) + ctx.hud.set_waypoint(mine) + toasts.clear() + filed.clear() + f.press_story() + K.check(fails, filed.size() == 1 and f.story.is_empty(), "K files the story") + var out: Dictionary = filed[0] if not filed.is_empty() else {} + var text := str(out.get("text", "")) + var lede := "From %s: %s." % [s["place"], s["headline"]] + var reads := text.begins_with(lede) and text.ends_with("says %s." % neighbour["name"]) + K.check(fails, reads, "it reads: where, what happened, the quote, who said it (%s)" % text) + var bonus := [int(out.get("points", 0)), out.get("scoop"), out.get("local"), out.get("sharp")] + var bonus_msg := "a scoop with a local voice and a sharp shot earns 200 (%s)" + K.check(fails, bonus == [200, true, true, true], bonus_msg % str(bonus)) + var refs := [int(out.get("tile", -1)), int(out.get("district", -9)), int(out.get("day", -1))] + var refs_ok := refs == [D.idx(home.x, home.y), district, ctx.sim.day] + K.check( + fails, refs_ok, "story_filed carries the tile, the district and the day (%s)" % str(refs) + ) + K.check(fails, f.points == 200 and f.filed == 1, "the press card counts it (%d)" % f.points) + var kept: Dictionary = f.stories[0] if not f.stories.is_empty() else {} + var listed := [str(kept.get("bonus", "")), str(kept.get("text", "")) == text] + K.check(fails, listed == ["scoop, local voice, sharp shot", true], "the latest stories keep it") + var scored := _count(toasts, "Story filed: +200 (scoop, local voice, sharp shot)") == 1 + var no_rank := _count(toasts, "Promoted") == 0 + K.check(fails, scored and no_rank, "a toast scores it; 200 is no promotion (%s)" % str(toasts)) + var wp: Variant = ctx.hud.waypoint() + K.check(fails, wp is Vector3 and _flat(wp, mine) < 0.01, "the player's own waypoint stays") + var mem: Array = neighbour.get("memory", []) + var last := str(mem[-1]) if not mem.is_empty() else "" + var quoted := last.ends_with("was quoted in the Gazette: " + str(s["headline"])) + K.check(fails, quoted, "the neighbour remembers being quoted (%s)" % last) + var line := _gazette_line(str(s["headline"])) + var refs_line: Dictionary = line.get("refs", {}) + var shown := [ + str(line.get("role", "")), str(line.get("kind", "")), int(refs_line.get("tile", -1)) + ] + var shown_ok := shown == ["press", "headline", D.idx(home.x, home.y)] + K.check(fails, shown_ok, "the Gazette runs it in the chat (%s)" % str(line.get("text", ""))) + K.check(fails, f.file_story().is_empty() and filed.size() == 1, "nothing left to file") + ctx.hud.clear_waypoint() + + +func _check_cold() -> void: + var n := f.leads.size() + f.leads[n - 1]["at"] = _now() - REPORTER.LEAD_DAYS - 0.1 + f._refresh() + var gone := f.leads.size() == n - 1 + K.check( + fails, gone, "a lead goes cold a day after it happened (%d -> %d)" % [n, f.leads.size()] + ) + ctx.player.teleport(_away(home, 8), 0.0) + f.take_lead(0) + var head := str(f.story["headline"]) + f.story["at"] = _now() - REPORTER.LEAD_DAYS - 0.1 + toasts.clear() + f._refresh() + var cold := f.story.is_empty() and _count(toasts, "The story went cold: " + head) == 1 + K.check(fails, cold, "so does the story on assignment (%s)" % str(toasts)) + K.check(fails, ctx.hud.waypoint() == null, "taking its waypoint with it") + f.take_lead(0) + var t: Vector2i = f.story["tile"] + f.story["at"] = _now() - REPORTER.SCOOP_DAYS - 0.05 + _stand_by(t) + f._process(REPORTER.REFRESH + 0.01) + var card := f.card.card_text() + K.check(fails, not card.contains("scoop "), "after a third of a day the scoop clock is gone") + toasts.clear() + ctx.bus.talk_replied.emit( + "", "Bus driver", "They never tell us anything until the diggers come." + ) + var by := str(f.story["by"]) + f.press_story() + var out: Dictionary = filed[-1] if not filed.is_empty() else {} + var late := int(out.get("points", 0)) == 100 and not bool(out.get("scoop", true)) + K.check(fails, late, "filed later it is no scoop: 100 points (%s)" % str(out.get("points"))) + var promoted := _count(toasts, "Promoted: you are a reporter now.") == 1 + K.check(fails, f.points == 300 and promoted, "300 points: promoted (%s)" % str(toasts)) + var own_wp := ctx.hud.waypoint() == null + K.check(fails, by == "a bus driver" and own_wp, "filing clears the story's own waypoint") + var detail := str(f.self_test()["detail"]) + K.check(fails, detail == "0 leads, 2 filed, Reporter", "self_test counts it (%s)" % detail) + + +# ------------------------------------------------------------------ keys and the league + + +func _check_keys() -> void: + var p := ctx.player + p.teleport(_away(home, 8), 0.0) + _mayor(district, home, {"type": "build", "building": "school", "x": home.x, "y": home.y}) + _mayor(district, home, {"type": "build", "building": "park", "x": home.x, "y": home.y}) + ctx.push_modal(&"test") + _send(K.action_event("press_story")) + var modal := f.story.is_empty() + ctx.pop_modal(&"test") + ctx.mode.paused = true + _send(K.action_event("press_story")) + var paused := f.story.is_empty() + ctx.mode.paused = false + p.set_photo_mode(true) + _send(K.action_event("press_story")) + var photo := f.story.is_empty() + p.set_photo_mode(false) + _send(K.key_event(KEY_K, true)) + var echo := f.story.is_empty() + var waits := [modal, paused, photo, echo] + var waits_msg := "K waits under a modal, paused and in photo mode; an echo does nothing (%s)" + K.check(fails, waits == [true, true, true, true], waits_msg % str(waits)) + _send(K.key_event(KEY_K)) + var head := "%s ordered a Park" % D.mayor_display(district) + var took := str(f.story.get("headline", "")) == head and f.leads.size() == 1 + K.check(fails, took, "K takes the newest lead") + toasts.clear() + _send(K.key_event(KEY_K)) + var needs := str(f.story.get("headline", "")) == head and _count(toasts, "Not filed yet") == 1 + K.check(fails, needs, "K again before the quote says what the story needs") + + +func _check_league_keys() -> void: + var p := ctx.player + _send(K.key_event(KEY_L)) + var opened := [f.league_open, ctx.has_modal(REPORTER.MODAL), f.league.visible, f.card.visible] + K.check( + fails, + opened == [true, true, true, false], + "L opens the league over the card (%s)" % str(opened) + ) + var head := str(f.story["headline"]) + toasts.clear() + _send(K.action_event("press_story")) + K.check( + fails, str(f.story["headline"]) == head and toasts.is_empty(), "K does nothing meanwhile" + ) + _send(K.key_event(KEY_L)) + var closed := [f.league_open, ctx.has_modal(REPORTER.MODAL), f.league.visible, f.card.visible] + K.check(fails, closed == [false, false, false, true], "L closes it again (%s)" % str(closed)) + _send(K.action_event("press_league")) + _send(K.action_event("pause_menu")) + K.check(fails, not f.league_open and not ctx.is_modal(), "so does Esc") + ctx.push_modal(&"test") + var over_modal := f.open_league() + ctx.pop_modal(&"test") + ctx.mode.paused = true + var while_paused := f.open_league() + ctx.mode.paused = false + p.set_photo_mode(true) + var in_photo := f.open_league() + p.set_photo_mode(false) + p.set("_conv_on", true) + var talking := f.open_league() + p.set("_conv_on", false) + var blocked := [over_modal, while_paused, in_photo, talking] + var blocked_msg := "it stays shut over a modal, paused, in photo mode or talking (%s)" + K.check(fails, blocked == [false, false, false, false], blocked_msg % str(blocked)) + var once := f.open_league() and not f.open_league() and ctx.modal_count() == 1 + ctx.bus.pause_changed.emit(true) + var shut := not f.league_open and not ctx.is_modal() + ctx.bus.pause_changed.emit(false) + K.check(fails, once and shut, "it opens once, and pausing closes it") + + +func _check_league() -> void: + f.open_league() + var lines := f.league.table_lines() + var rows := DESK.standings(ctx.sim, ctx.residents) + var same := 0 + for k in range(mini(lines.size(), rows.size())): + same += int(lines[k] == _row_line(k, rows[k])) + var first := lines[0] if not lines.is_empty() else "" + var rows_msg := "a row per mayor: rank, name and model, district, numbers, score (%s)" + K.check(fails, lines.size() == D.MAYORS.size() and same == lines.size(), rows_msg % first) + K.check(fails, _badges(f.league) == D.MAYORS.size(), "each mayor wears their model's badge") + var press: Label = f.league.get("_press") + var card := "Reporter · 300 points · 2 filed · Senior reporter at 1,000" + K.check( + fails, + press.text == card, + "the press card: rank, points, stories, next rank (%s)" % press.text + ) + var on: Label = f.league.get("_assignment") + var on_ok := on.text == "On assignment: " + str(f.story["headline"]) + K.check(fails, on_ok, "and the story on assignment (%s)" % on.text) + var listed := S.owned_texts(f.league.get("_stories")) + var heads := [listed[0], listed[2]] if listed.size() == 4 else [] + var heads_ok := heads == ["+100", "+200 · scoop, local voice, sharp shot"] + K.check(fails, heads_ok, "the latest stories, newest first, with points (%s)" % str(listed)) + var branded: Array[String] = [] + for n: Node in [f.league, f.card]: + for t in S.owned_texts(n): + if S.branded(t): + branded.append(t) + K.check(fails, branded.is_empty(), "no model names outside the badges (%s)" % str(branded)) + var chase := f.league.chase_buttons() + var one := chase.size() == 1 and f.leads.size() == 1 + K.check(fails, one, "a Chase button per open lead (%d)" % chase.size()) + if chase.is_empty() or f.leads.is_empty(): + return + var want := str(f.leads[0]["headline"]) + chase[0].pressed.emit() + var chased := [f.league_open, ctx.has_modal(REPORTER.MODAL), str(f.story.get("headline", ""))] + var chased_ok := chased == [false, false, want] + K.check(fails, chased_ok, "Chase closes the league and takes that lead (%s)" % str(chased)) + + +# ------------------------------------------------------------------ save, photo tour, teardown + + +func _check_save() -> void: + var d := f.to_dict() + var stories: Array = d.get("stories", []) + var saved := [int(d.get("points", 0)), int(d.get("filed", 0)), stories.size(), d.get("tipped")] + var saved_msg := "to_dict: points, stories filed, the latest stories, the tip (%s)" + K.check(fails, saved == [300, 2, 2, true], saved_msg % str(saved)) + f.open_league() + f.from_dict({}) + var fresh := [f.points, f.filed, f.stories.size(), f.to_dict().is_empty()] + K.check( + fails, fresh == [0, 0, 0, true], "a save with no press card starts afresh (%s)" % str(fresh) + ) + var dropped := [ + f.leads.size(), f.story.is_empty(), f.league_open, ctx.has_modal(REPORTER.MODAL) + ] + var dropped_ok := dropped == [0, true, false, false] + K.check(fails, dropped_ok, "a load drops the leads and the story and closes the league") + f.from_dict(JSON.parse_string(JSON.stringify(d))) + var back := [f.points, f.filed, str(f.stories) == str(stories)] + K.check( + fails, back == [300, 2, true], "the round trip through JSON restores it (%s)" % str(back) + ) + var junk := {"points": -5, "filed": "lots", "stories": [7, "x", {"text": "Old news."}]} + f.from_dict(junk) + var old := str(f.stories[0]["text"]) if f.stories.size() == 1 else "" + var safe := [f.points, f.filed, old] + K.check(fails, safe == [0, 0, "Old news."], "a damaged save is read safely (%s)" % str(safe)) + var many := [] + for k in range(9): + many.append({"text": "Story %d." % k, "points": 100}) + f.from_dict({"stories": many}) + var keep := REPORTER.KEEP_STORIES + K.check(fails, f.stories.size() == keep, "it keeps the latest %d stories" % keep) + f.from_dict(d) + _mayor(district, home, {"type": "road", "x": home.x, "y": home.y}) + f.take_lead(0) + f.on_world_rebuilt() + var rebuilt := [f.leads.size(), f.story.is_empty(), f.points] + var rebuilt_msg := "a rebuilt world drops the leads and the story, not the press card (%s)" + K.check(fails, rebuilt == [0, true, 300], rebuilt_msg % str(rebuilt)) + + +## The three shots the photo tour takes, run as the tour runs them: prepare, the teleport, frames. +func _check_photo_tour() -> void: + var p := ctx.player + ctx.brain.council_ruled.emit({"enacted": ["free_clinics"], "repealed": []}) + var shots := f.photo_shots() + var names := shots.map(func(s: Dictionary) -> String: return str(s["name"])) + var names_ok := names == ["reporter_story", "reporter_photo", "reporter_league"] + K.check(fails, names_ok, "three tour shots (%s)" % str(names)) + var hooks := shots.all( + func(s: Dictionary) -> bool: + return f.has_method(s["prepare"]) and f.has_method(s["restore"]) + ) + K.check(fails, hooks, "each with a prepare and a restore") + if shots.size() < 3: + return + await _tour_shot(shots[0]) + var staged := [str(f.story.get("headline", "")), str(f.story.get("by", "")), f.card.visible] + var staged_ok := staged == ["The council passed the Free Clinics", "a shopkeeper", true] + K.check( + fails, staged_ok, "the story shot: on assignment, a shopkeeper quoted (%s)" % str(staged) + ) + f.call(str(shots[0]["restore"])) + await _tour_shot(shots[1]) + var note := f.finder.note_text() + var camera := p.photo_mode and f.finder.visible and f.finder.framed and note == SHARP + K.check(fails, camera, "the photo shot: the press camera up, a sharp shot taken (%s)" % note) + f.call(str(shots[1]["restore"])) + K.check(fails, not p.photo_mode, "restore puts the camera down") + var n := f.filed + await _tour_shot(shots[2]) + K.check( + fails, + f.filed == n + 1 and f.league_open, + "the league shot: the story filed, the league open" + ) + f.call(str(shots[2]["restore"])) + K.check(fails, not f.league_open and not ctx.is_modal(), "restore closes it") + + +func _check_teardown() -> void: + f.open_league() + var n := f.leads.size() + f.teardown() + var gone := [ctx.has_modal(REPORTER.MODAL), f.card == null, f.finder == null, f.league == null] + var gone_msg := "teardown closes the league and frees the UI (%s)" + K.check(fails, gone == [false, true, true, true], gone_msg % str(gone)) + _mayor(district, home, {"type": "road", "x": home.x, "y": home.y}) + ctx.bus.talk_replied.emit("", "Shopkeeper", QUOTE) + K.check(fails, f.leads.size() == n, "and stops listening") + + +# ------------------------------------------------------------------ helpers + + +func _mayor(mi: int, focus: Vector2i, action: Dictionary) -> void: + var turn := {"focus": focus, "actions": [{"ok": true, "action": action}], "post": ""} + ctx.brain.mayor_decided.emit(mi, turn) + + +## Sim time in days, as the feature reads it. +func _now() -> float: + return float(ctx.sim.day) + ctx.sim.day_progress() + + +## A point n tiles east of tile t (west near the map's edge). +func _away(t: Vector2i, n: int) -> Vector3: + var x := t.x + n if D.in_bounds(t.x + n, t.y) else t.x - n + return Vector3(x, 0.0, t.y) + + +## Puts the hero 1.5 tiles south of tile t, facing it, and settles the camera. +func _stand_by(t: Vector2i) -> void: + ctx.player.teleport(Vector3(t.x, 0.0, t.y + 1.5), 0.0) + for _k in range(5): + ctx.player.update_view(DT) + + +## The point a press photo of tile t must hold. +func _subject(t: Vector2i) -> Vector3: + var at := Vector3(t.x, 0.0, t.y) + var g := ctx.collision.ground_height(at) + at.y = (0.0 if is_nan(g) else g) + REPORTER.SUBJECT_Y + return at + + +## Turns the photo-mode camera to look straight at target. +func _aim_at(target: Vector3) -> void: + var p := ctx.player + var d := (target - p.camera.global_position).normalized() + p.cam_yaw = atan2(-d.x, -d.z) + p.cam_pitch = asin(clampf(d.y, -1.0, 1.0)) + p.update_view(0.0) + + +func _flat(a: Vector3, b: Vector3) -> float: + return Vector2(a.x - b.x, a.z - b.z).length() + + +func _within(v: float, hi: float) -> bool: + return v >= 0.0 and v <= hi + + +## A league row as table_lines() reads it. +func _row_line(k: int, r: Dictionary) -> String: + var cells := [ + str(k + 1), + "%s %s" % [r["who"], r["model"]], + str(r["district"]), + S.fmt(int(r["pop"])), + S.fmt(int(r["jobs"])), + "%.1f/10" % float(r["approval"]), + S.money(int(r["funds"])), + str(int(r["score"])), + ] + return " · ".join(PackedStringArray(cells)) + + +## Labels under n marked as a model's badge. +func _badges(n: Node) -> int: + var c := int(n is Label and bool(n.get_meta(S.BADGE_META, false))) + for k in n.get_children(): + c += _badges(k) + return c + + +## The Gazette's chat line for the story with this headline, once the drip shows it ({} if never). +func _gazette_line(headline: String) -> Dictionary: + var feed: ChatFeed = chat.get("feed") + for _k in range(120): + for m: Dictionary in feed.messages: + if ( + str(m["author_id"]).begins_with("press:story:") + and str(m["text"]).contains(headline) + ): + return m + feed.pump(0.25) + return {} + + +func _pump(secs: float) -> void: + var feed: ChatFeed = chat.get("feed") + for _k in range(ceili(secs / 0.25)): + feed.pump(0.25) + + +## Runs a photo-tour shot as the tour does: prepare, the teleport, then a few frames. +func _tour_shot(s: Dictionary) -> void: + f.call(str(s["prepare"])) + ctx.player.teleport(s["pos"], float(s.get("yaw", 0.0)), float(s.get("pitch", 0.0))) + for _k in range(REPORTER.PHOTO_FRAMES + 4): + await process_frame + + +func _has_all(t: String, parts: Array) -> bool: + for s: Variant in parts: + if not t.contains(str(s)): + return false + return true + + +## How many of list contain needle. +func _count(list: Array[String], needle: String) -> int: + var n := 0 + for t: String in list: + if t.contains(needle): + n += 1 + return n + + +# ------------------------------------------------------------------ input + + +func _send(e: InputEvent) -> void: + f._unhandled_input(e) + + +## Every key bound to action (its physical key where it has one). +func _keys(action: StringName) -> Array[int]: + var out: Array[int] = [] + for e: InputEvent in InputMap.action_get_events(action): + var k := e as InputEventKey + if k != null: + out.append(int(k.physical_keycode if k.physical_keycode != KEY_NONE else k.keycode)) + return out diff --git a/tools/tests/fp_reporter_test.gd.uid b/tools/tests/fp_reporter_test.gd.uid new file mode 100644 index 00000000..48950c4c --- /dev/null +++ b/tools/tests/fp_reporter_test.gd.uid @@ -0,0 +1 @@ +uid://tlmoy46d71qy