atagen

jelq

public

an http based roguelike rpg about jelqing

Star0
Watch0

Commit f3a1913c1299

tarball

atagen <boss@atagen.co>2026-10-14 11:49 UTC

9 files changed+104-7
game: flag dangerous and critical gauges in X-Danger

The thresholds live in the balance constants, so the server names the level and the UI only colours it. Hard-coding them in the page would have been another copy to drift. LOW_STAMINA names the 2 that can_hold already used.
Modified file: src/game/balance/stamina.rs+2 additions
@@ -1,4 +1,6 @@
11 pub const MAX_STAMINA: i32 = 10;
2+/// At or below this, HOLD opens up and the gauge reads as dangerous.
3+pub const LOW_STAMINA: i32 = 2;
24 pub const PULLED_STAMINA: i32 = 7;
35 pub const EDGE_LOOP: u8 = 3;
46 pub const ENERGY_STAMINA: i32 = 3;
Added file: src/game/danger.rs+59 additions
@@ -0,0 +1,59 @@
1+use super::{
2+ Arousal, Run,
3+ balance::{FRICTION_BURN_AT, FRICTION_MAX, LOW_STAMINA, SUSPICION_LIMIT},
4+};
5+
6+/// How close a gauge is to hurting the run, so a client can flag it without knowing the balance.
7+#[derive(Clone, Copy, PartialEq, Eq, Debug)]
8+pub enum Danger {
9+ Dangerous,
10+ Critical,
11+}
12+
13+impl Danger {
14+ pub fn name(self) -> &'static str {
15+ match self {
16+ Self::Dangerous => "dangerous",
17+ Self::Critical => "critical",
18+ }
19+ }
20+}
21+
22+impl Arousal {
23+ fn danger(self) -> Option<Danger> {
24+ match self {
25+ Self::Cold | Self::Swollen | Self::Throbbing => Some(Danger::Dangerous),
26+ Self::Straining | Self::Imminent | Self::Priapic => Some(Danger::Critical),
27+ Self::Flaccid | Self::Warm | Self::Ideal => None,
28+ }
29+ }
30+}
31+
32+impl Run {
33+ pub(super) fn dangers(&self) -> Vec<(&'static str, Danger)> {
34+ let friction = if self.body.friction >= FRICTION_MAX || self.body.conditions.friction_burn {
35+ Some(Danger::Critical)
36+ } else {
37+ (self.body.friction >= FRICTION_BURN_AT).then_some(Danger::Dangerous)
38+ };
39+ let stamina = if self.body.stamina <= 0 {
40+ Some(Danger::Critical)
41+ } else {
42+ (self.body.stamina <= LOW_STAMINA).then_some(Danger::Dangerous)
43+ };
44+ let suspicion = if self.standing.suspicion + 1 >= SUSPICION_LIMIT {
45+ Some(Danger::Critical)
46+ } else {
47+ (self.standing.suspicion + 2 >= SUSPICION_LIMIT).then_some(Danger::Dangerous)
48+ };
49+ [
50+ ("friction", friction),
51+ ("eq", self.arousal().danger()),
52+ ("stamina", stamina),
53+ ("suspicion", suspicion),
54+ ]
55+ .into_iter()
56+ .filter_map(|(gauge, danger)| Some((gauge, danger?)))
57+ .collect()
58+ }
59+}
Modified file: src/game/gauges.rs+3 additions−1 deletions
@@ -3,7 +3,7 @@ use std::time::Instant;
33 use serde_json::{Value, json};
44
55 use super::{
6- Arousal, Arrival, Elder, Excuse, Floor, Grip, Item, Observer, Run, Story, Technique,
6+ Arousal, Arrival, Danger, Elder, Excuse, Floor, Grip, Item, Observer, Run, Story, Technique,
77 balance::{DANGER_EQ, FLOOR_LIMIT},
88 techniques::{Training, Utility},
99 };
@@ -27,6 +27,7 @@ pub struct Gauges {
2727 pub(crate) turn: u32,
2828 pub(crate) floor_turns: u32,
2929 pub(crate) floor_limit: Option<u32>,
30+ pub(crate) dangers: Vec<(&'static str, Danger)>,
3031 pub(crate) observed: Option<Observer>,
3132 pub(crate) mum_challenge: Option<String>,
3233 pub(crate) warnings: Vec<&'static str>,
@@ -86,6 +87,7 @@ impl Run {
8687 turn: self.turn,
8788 floor_turns: self.here.turns,
8889 floor_limit: self.floor.timed().then_some(FLOOR_LIMIT),
90+ dangers: self.dangers(),
8991 observed: self.observed,
9092 mum_challenge: self.mum_challenge(),
9193 warnings,
Modified file: src/game/mod.rs+2 additions
@@ -5,6 +5,7 @@ mod cause;
55 mod checks;
66 mod clinic;
77 mod credentials;
8+mod danger;
89 mod dispatch;
910 mod drawer;
1011 mod floor;
@@ -36,6 +37,7 @@ pub use body::Pending;
3637 pub use body::{Arrival, Body, Grip};
3738 pub use cause::{Cause, Ending, RunStatus};
3839 pub use clinic::Clinic;
40+pub use danger::Danger;
3941 pub use floor::{Floor, FloorState, Observer};
4042 pub use forum::{Branch, ELDER_LAST_MODIFIED, Elder, FORUM_THREAD, Online, Story, forum_thread};
4143 pub use gauges::{Gauges, Shown};
Modified file: src/game/rest.rs+3 additions−3 deletions
@@ -1,8 +1,8 @@
11 use super::{
22 Notice, Outcome, Run, Story,
33 balance::{
4- DANGER_EQ, FRICTION_HOLD, FRICTION_MEDITATE, HOLD_EQ, MEDITATION_EQ, REFLECTION,
5- RUPTURE_HOLD,
4+ DANGER_EQ, FRICTION_HOLD, FRICTION_MEDITATE, HOLD_EQ, LOW_STAMINA, MEDITATION_EQ,
5+ REFLECTION, RUPTURE_HOLD,
66 },
77 };
88 use crate::problem::Problem;
@@ -11,7 +11,7 @@ use serde_json::json;
1111
1212 impl Run {
1313 pub fn can_hold(&self) -> bool {
14- self.hotter_than(DANGER_EQ) || self.body.stamina <= 2
14+ self.hotter_than(DANGER_EQ) || self.body.stamina <= LOW_STAMINA
1515 }
1616
1717 pub fn hold(&mut self) -> Result<Outcome, Problem> {
Modified file: src/headers.rs+1 additions
@@ -2,6 +2,7 @@ use axum::http::HeaderName;
22
33 pub const SERVER_TIMING: HeaderName = HeaderName::from_static("server-timing");
44 pub const X_CASH: HeaderName = HeaderName::from_static("x-cash");
5+pub const X_DANGER: HeaderName = HeaderName::from_static("x-danger");
56 pub const X_DIAGNOSIS: HeaderName = HeaderName::from_static("x-diagnosis");
67 pub const X_EQ: HeaderName = HeaderName::from_static("x-eq");
78 pub const X_FLOOR: HeaderName = HeaderName::from_static("x-floor");
Modified file: src/http/run.rs+10 additions−2 deletions
@@ -16,8 +16,8 @@ use crate::{
1616 balance::{FRICTION_MAX, SUSPICION_LIMIT},
1717 },
1818 headers::{
19- X_CASH, X_EQ, X_FLOOR, X_FLOOR_TURN, X_FORESKIN, X_FRICTION, X_GAINS_ARRIVED, X_GIRTH,
20- X_LENGTH, X_OBSERVED_BY, X_RUN_OVER, X_SELF_REPORTED_LENGTH, X_SHAME, X_STAMINA,
19+ X_CASH, X_DANGER, X_EQ, X_FLOOR, X_FLOOR_TURN, X_FORESKIN, X_FRICTION, X_GAINS_ARRIVED,
20+ X_GIRTH, X_LENGTH, X_OBSERVED_BY, X_RUN_OVER, X_SELF_REPORTED_LENGTH, X_SHAME, X_STAMINA,
2121 X_SUSPICION, X_TURN,
2222 },
2323 locale::Locale,
@@ -175,6 +175,14 @@ fn stats(gauges: &Gauges, shame: u32) -> Vec<(HeaderName, String)> {
175175 }),
176176 ),
177177 ];
178+ if !gauges.dangers.is_empty() {
179+ let dangers: Vec<String> = gauges
180+ .dangers
181+ .iter()
182+ .map(|(gauge, danger)| format!("{gauge}={}", danger.name()))
183+ .collect();
184+ stats.push((X_DANGER, dangers.join(", ")));
185+ }
178186 if let Some(observer) = gauges.observed {
179187 stats.push((X_OBSERVED_BY, observer.name().into()));
180188 }
Modified file: src/tests/turns.rs+19 additions
@@ -466,3 +466,22 @@ async fn eq_is_named_rather_than_numbered() {
466466 assert_eq!(header(&view, "x-eq"), named, "{eq} with {setup:?}");
467467 }
468468 }
469+
470+#[tokio::test]
471+async fn gauges_near_the_edge_are_flagged() {
472+ let calm = seeded_with(Floor::Bedroom, |run| run.body.eq = 60);
473+ let view = send(&calm, "GET", "/runs/cafebabe", &[], "").await;
474+ assert!(view.headers().get("x-danger").is_none());
475+
476+ let tense = seeded_with(Floor::Bedroom, |run| {
477+ run.body.eq = 93;
478+ run.body.friction = 6;
479+ run.body.stamina = 2;
480+ run.standing.suspicion = 4;
481+ });
482+ let view = send(&tense, "GET", "/runs/cafebabe", &[], "").await;
483+ assert_eq!(
484+ header(&view, "x-danger"),
485+ "friction=dangerous, eq=critical, stamina=dangerous, suspicion=critical"
486+ );
487+}
Modified file: src/ui.html+5 additions−1 deletions
@@ -76,6 +76,7 @@ const MEASURED = ["x-length", "Length"];
7676 const GROWTH = new Set(["x-length", "x-self-reported-length", "x-girth", "x-foreskin"]);
7777 const BETTER = { "x-length": 1, "x-self-reported-length": 1, "x-girth": 1, "x-foreskin": 1, "x-friction": -1, "x-stamina": 1, "x-suspicion": -1, "x-shame": -1, "x-cash": 1, "x-floor": 1 };
7878 const AROUSAL = ["Flaccid", "Cold", "Warm", "Ideal", "Swollen", "Throbbing", "Straining", "Imminent"];
79+const DANGER_COLOURS = { dangerous: "#b8860b", critical: "red" };
7980 let lastStats = {};
8081 const QUIET = new Set(["link", "set-cookie", "server", "x-powered-by", "cache-control", "content-length", "content-type", "date"].concat(STATS.map(s => s[0]), MEASURED[0]));
8182
@@ -217,6 +218,7 @@ function chooseTarget(t) {
217218 function renderStats(headers) {
218219 const table = $("stats");
219220 table.replaceChildren();
221+ const dangers = Object.fromEntries((headers && headers["x-danger"] || "").split(",").filter(Boolean).map(d => d.trim().split("=")));
220222 const stats = STATS.map(s => s[0] === "x-self-reported-length" && headers && MEASURED[0] in headers ? MEASURED : s);
221223 for (let i = 0; i < stats.length; i += 2) {
222224 const row = el("tr");
@@ -226,7 +228,9 @@ function renderStats(headers) {
226228 const changed = value === undefined ? null : change(name, value);
227229 if (changed) cell.append(" ", changed);
228230 const measured = name === MEASURED[0] && "x-self-reported-length" in lastStats;
229- row.append(el("th", { align: "left", textContent: label, style: measured ? "color: green" : "" }), cell);
231+ const danger = DANGER_COLOURS[dangers[name.slice(2)]];
232+ const colour = danger || (measured ? "green" : "");
233+ row.append(el("th", { align: "left", textContent: label, style: colour ? "color: " + colour : "" }), cell);
230234 }
231235 table.append(row);
232236 }