fix: integrate multiplayer

This commit is contained in:
Henri Bourcereau 2026-04-23 20:54:52 +02:00
parent 3f3f4598f6
commit 82803ded36
10 changed files with 75 additions and 2308 deletions

1
.gitignore vendored
View file

@ -17,3 +17,4 @@ client_web/dist
var
deploy
clients/**/dist

View file

@ -1194,3 +1194,20 @@ body {
color: var(--ui-red-accent);
font-style: italic;
}
.auth-badge {
font-size: 0.8rem;
text-align: center;
padding: 0.35rem 0.6rem;
border-radius: 5px;
}
.auth-badge--in { background: rgba(96,165,250,0.15); color: #93c5fd; }
.auth-badge--out { background: rgba(148,163,184,0.1); color: #64748b; }
.auth-badge a { color: #60a5fa; }
.playing-as {
font-size: 0.8rem;
color: #64748b;
text-align: center;
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -19,7 +19,7 @@ use trictrac_store::CheckerMove;
use std::collections::VecDeque;
const RELAY_URL: &str = "ws://127.0.0.1:8080/ws";
const RELAY_URL: &str = "ws://localhost:8080/ws";
const GAME_ID: &str = "trictrac";
const STORAGE_KEY: &str = "trictrac_session";
@ -152,6 +152,23 @@ pub fn App() -> impl IntoView {
};
let screen = RwSignal::new(initial_screen);
// Auth: fetch once and expose to all child components via context.
let auth_username: RwSignal<Option<String>> = RwSignal::new(None);
provide_context(auth_username);
spawn_local(async move {
if let Ok(resp) = gloo_net::http::Request::get(&format!("{HTTP_BASE}/auth/me"))
.credentials(web_sys::RequestCredentials::Include)
.send()
.await
{
if resp.status() == 200 {
if let Ok(me) = resp.json::<MeResponse>().await {
auth_username.set(Some(me.username));
}
}
}
});
let (cmd_tx, mut cmd_rx) = mpsc::unbounded::<NetCommand>();
let pending: RwSignal<VecDeque<GameUiState>> = RwSignal::new(VecDeque::new());
provide_context(pending);
@ -275,6 +292,7 @@ pub fn App() -> impl IntoView {
let player_id = session.player_id;
let reconnect_token = session.reconnect_token;
let mut vs = ViewState::default_with_names("Blancs", "Noirs");
let mut result_submitted = false;
loop {
futures::select! {
@ -297,6 +315,15 @@ pub fn App() -> impl IntoView {
ViewStateUpdate::Full(state) => vs = state,
ViewStateUpdate::Incremental(delta) => vs.apply_delta(&delta),
}
// Host reports outcomes once per terminal game state.
if is_host && !result_submitted && vs.stage == SerStage::Ended {
result_submitted = true;
let room = room_id_for_storage.clone();
let gs = vs.clone();
spawn_local(submit_game_result(room, gs));
}
if is_host {
save_session(&StoredSession {
relay_url: RELAY_URL.to_string(),

View file

@ -18,6 +18,8 @@ use super::scoring::ScoringPanel;
pub fn GameScreen(state: GameUiState) -> impl IntoView {
let i18n = use_i18n();
let auth_username =
use_context::<RwSignal<Option<String>>>().expect("auth_username not found in context");
let vs = state.view_state.clone();
let player_id = state.player_id;
let is_my_turn = vs.active_mp_player == Some(player_id);
@ -240,6 +242,11 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
on:click=move |_| i18n.set_locale(Locale::fr)
>"FR"</button>
</div>
{move || auth_username.get().map(|u| view! {
<p class="playing-as">"Playing as " <strong>{u}</strong></p>
})}
<a class="quit-link" href="#" on:click=move |e| {
e.prevent_default();
cmd_tx_quit.unbounded_send(NetCommand::Disconnect).ok();

View file

@ -4,6 +4,11 @@ use leptos::prelude::*;
use crate::app::NetCommand;
use crate::i18n::*;
#[cfg(debug_assertions)]
const PORTAL_URL: &str = "http://localhost:9092";
#[cfg(not(debug_assertions))]
const PORTAL_URL: &str = "/portal";
#[component]
pub fn LoginScreen(error: Option<String>) -> impl IntoView {
let i18n = use_i18n();
@ -11,6 +16,8 @@ pub fn LoginScreen(error: Option<String>) -> impl IntoView {
let cmd_tx = use_context::<UnboundedSender<NetCommand>>()
.expect("UnboundedSender<NetCommand> not found in context");
let auth_username =
use_context::<RwSignal<Option<String>>>().expect("auth_username not found in context");
let cmd_tx_create = cmd_tx.clone();
let cmd_tx_join = cmd_tx.clone();
@ -47,6 +54,19 @@ pub fn LoginScreen(error: Option<String>) -> impl IntoView {
{error.map(|err| view! { <p class="error-msg">{err}</p> })}
// Auth status badge
{move || match auth_username.get() {
Some(u) => view! {
<p class="auth-badge auth-badge--in">"✓ Logged in as " <strong>{u}</strong></p>
}.into_any(),
None => view! {
<p class="auth-badge auth-badge--out">
"Not logged in — games won't be tracked. "
<a href=PORTAL_URL target="_blank">"Create account"</a>
</p>
}.into_any(),
}}
<input
class="login-input"
type="text"

View file

@ -131,6 +131,7 @@ mod inner {
/// Play the pre-recorded dice-roll MP3 asset.
pub fn play_dice_roll() {
if let Ok(audio) = web_sys::HtmlAudioElement::new_with_src("/diceroll.mp3") {
audio.set_volume(0.2);
let _ = audio.play();
}
}

View file

@ -41,7 +41,7 @@ build-relay:
CARGO_PROFILE_RELEASE_OPT_LEVEL=3 cargo build -p relay-server --release
mkdir -p deploy
cp target/release/relay-server deploy
cp -u service/relay-server/GameConfig.json deploy/
cp -u server/relay-server/GameConfig.json deploy/
runclibots:
cargo run --bin=client_cli -- --bot random,dqnburn:./bot/models/burnrl_dqn_40.mpk