Skip to main content

chaser_util/
poll_realtime_map_view.rs

1//! Polling wrapper for the CHaser Online real-time map view.
2//!
3//! Authenticates once and reuses the JSESSIONID across all subsequent fetches.
4//! Re-authenticates automatically only when the session expires.
5//!
6//! # Quick start
7//!
8//! ```no_run
9//! use std::time::Duration;
10//! use chaser_util::poll_realtime_map_view::{poll_map_view, PollOptions};
11//!
12//! #[tokio::main]
13//! async fn main() {
14//!     let mut rx = poll_map_view("hot", "hot", Duration::from_secs(2), PollOptions::default());
15//!     while let Some(mv) = rx.recv().await {
16//!         println!("turn={} next={}", mv.turn, mv.next_player);
17//!     }
18//! }
19//! ```
20
21use std::time::Duration;
22use tokio::sync::mpsc;
23use encoding_rs::SHIFT_JIS;
24
25use crate::proxy::{send_once, url_encode, BoxError, ProxyMode};
26use crate::realtime_map_view::{
27    parse_map, parse_players, parse_room_name, MapViewResult,
28};
29
30// ----------------------------------------------------------------
31// Public types
32// ----------------------------------------------------------------
33
34/// Options for `poll_map_view`.
35#[derive(Debug, Clone, Default)]
36pub struct PollOptions {
37    /// Optional proxy URI.  `None` = auto-detect, `Some("")` = direct connection.
38    pub proxy_uri: Option<String>,
39}
40
41// ----------------------------------------------------------------
42// URLs
43// ----------------------------------------------------------------
44
45const SERVER_CHECK_URL: &str =
46    "http://www7019ug.sakura.ne.jp/CHaserOnline003/Server/UserCheck";
47const MAP_VIEW_URL: &str =
48    "http://www7019ug.sakura.ne.jp/CHaserOnline003/Server/MapView.jsp";
49
50// ----------------------------------------------------------------
51// Session management
52// ----------------------------------------------------------------
53
54/// Authenticates and returns a JSESSIONID.
55/// FIX: user/pass are percent-encoded to prevent query parameter injection.
56async fn authenticate(
57    user:       &str,
58    pass:       &str,
59    proxy_mode: &ProxyMode,
60) -> Result<String, BoxError> {
61    let url = format!(
62        "{}?user={}&pass={}&select=mapview",
63        SERVER_CHECK_URL,
64        url_encode(user),
65        url_encode(pass),
66    );
67    let (_, headers, _) = send_once(&url, &[], proxy_mode).await?;
68    for val in headers.get_all("set-cookie").iter() {
69        for part in val.to_str().unwrap_or("").split(';') {
70            if let Some(id) = part.trim().strip_prefix("JSESSIONID=") {
71                return Ok(id.to_string());
72            }
73        }
74    }
75    Err("JSESSIONID not found in authentication response".into())
76}
77
78/// Fetches MapView.jsp using an existing JSESSIONID.
79/// Returns `None` if the session has expired.
80async fn fetch_map(
81    jsessionid: &str,
82    proxy_mode: &ProxyMode,
83) -> Result<Option<MapViewResult>, BoxError> {
84    let cookie = format!("JSESSIONID={}", jsessionid);
85    let (status, _, body) =
86        send_once(MAP_VIEW_URL, &[("Cookie", cookie)], proxy_mode).await?;
87
88    // Session expired: server redirects (3xx)
89    if status >= 300 {
90        return Ok(None);
91    }
92
93    let (html, _, _) = SHIFT_JIS.decode(&body);
94
95    // Detect login form response (session expired without redirect)
96    if html.contains("UserCheck") && html.contains(r#"type="text" name="user""#) {
97        return Ok(None);
98    }
99
100    let room_name                = parse_room_name(&html);
101    let (map, turn, next_player) = parse_map(&html);
102    let dom                      = tl::parse(&html, tl::ParserOptions::default())?;
103    let players                  = parse_players(&dom);
104
105    Ok(Some(MapViewResult { room_name, turn, next_player, map, players }))
106}
107
108// ----------------------------------------------------------------
109// Public API
110// ----------------------------------------------------------------
111
112/// Starts polling the map view at the given interval.
113///
114/// Errors during polling are logged to `eprintln!` rather than silently
115/// discarded, making it possible to diagnose network and parse failures
116/// without attaching a dedicated logging framework.
117///
118/// The background task stops when the returned `Receiver` is dropped.
119pub fn poll_map_view(
120    user:     impl Into<String>,
121    pass:     impl Into<String>,
122    interval: Duration,
123    opts:     PollOptions,
124) -> mpsc::Receiver<MapViewResult> {
125    let user = user.into();
126    let pass = pass.into();
127
128    let (tx, rx) = mpsc::channel(32);
129
130    tokio::spawn(async move {
131        let proxy_mode = ProxyMode::from_option(opts.proxy_uri.as_deref());
132
133        // Authenticate once at startup; retry on failure with logging.
134        let mut jsessionid = loop {
135            match authenticate(&user, &pass, &proxy_mode).await {
136                Ok(id)   => break id,
137                // FIX: errors are no longer silently swallowed.
138                Err(e)   => {
139                    eprintln!("[poll_map_view] authentication failed: {e}; retrying in {interval:?}");
140                    tokio::time::sleep(interval).await;
141                }
142            }
143        };
144
145        loop {
146            match fetch_map(&jsessionid, &proxy_mode).await {
147                Ok(Some(result)) => {
148                    if tx.send(result).await.is_err() {
149                        // Receiver dropped; stop polling cleanly.
150                        break;
151                    }
152                }
153                Ok(None) => {
154                    // Session expired; re-authenticate.
155                    eprintln!("[poll_map_view] session expired, re-authenticating…");
156                    loop {
157                        match authenticate(&user, &pass, &proxy_mode).await {
158                            Ok(id) => {
159                                jsessionid = id;
160                                break;
161                            }
162                            Err(e) => {
163                                eprintln!("[poll_map_view] re-authentication failed: {e}; retrying in {interval:?}");
164                                tokio::time::sleep(interval).await;
165                            }
166                        }
167                    }
168                }
169                // FIX: network / parse errors are logged instead of silently skipped.
170                Err(e) => {
171                    eprintln!("[poll_map_view] fetch error: {e}; skipping tick");
172                }
173            }
174
175            tokio::time::sleep(interval).await;
176        }
177    });
178
179    rx
180}