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}