From 60f550a2b78d0be555d6bfc508c7450067c36e40 Mon Sep 17 00:00:00 2001 From: vtroc7 Date: Wed, 30 Sep 2026 04:38:21 +0300 Subject: [PATCH] Support personal loadouts and fix lobby and menu interactions --- Makefile | 24 ++-- README.md | 2 +- .../src/scenarios/heavy_gameplay_lifecycle.rs | 2 +- crates/console/src/class_menu.rs | 75 +++++++---- crates/console/src/user_settings.rs | 33 +++-- crates/hud/src/killfeed.rs | 6 +- crates/hud/src/menus/mod.rs | 12 ++ crates/hud/src/menus/runtime.rs | 7 + crates/hud/src/menus/script.rs | 1 + crates/master_protocol/src/lib.rs | 2 +- crates/net/src/client/predict.rs | 21 ++- .../presentation/entity_event_dispatch.rs | 12 +- crates/net/src/lib.rs | 2 +- crates/net/src/transport/meta_wire.rs | 18 +++ crates/net/src/transport/protocol.rs | 2 +- crates/session/src/lifecycle.rs | 6 +- crates/session/src/loadout.rs | 125 +++++++++++------- crates/session/src/match_apply.rs | 21 +-- crates/sim/src/content.rs | 34 ++--- crates/sim/src/input.rs | 1 + crates/sim/src/lib.rs | 8 +- crates/sim/src/match_state.rs | 5 +- crates/sim/src/match_state/loadout.rs | 35 +++++ crates/sim/src/script/host/natives/t5.rs | 15 +-- crates/sim/src/script/host/players.rs | 73 +++++++--- crates/sim/src/script/host/restart.rs | 2 + crates/sim/src/script/mod.rs | 2 +- crates/sim/src/script/runtime/state.rs | 1 + crates/sim/src/spawn.rs | 7 - crates/sim/src/step.rs | 70 ++++------ crates/ui/menus/frontend.json | 40 +++--- crates/ui/src/classes/equip_txn.rs | 43 +++++- crates/ui/src/classes/select.rs | 6 +- crates/ui/src/classes/setup.rs | 114 ++++++++++------ crates/ui/src/classes/store.rs | 115 ++++++++++++---- docs/DUO.md | 4 +- docs/RUN.md | 7 +- xtask/src/duo.rs | 2 +- 38 files changed, 606 insertions(+), 349 deletions(-) diff --git a/Makefile b/Makefile index 73e2549..940d6f2 100644 --- a/Makefile +++ b/Makefile @@ -21,8 +21,8 @@ $(ARGS): # Console script played into the running game. `make` itself cannot carry an # unknown `--cmds` flag (it parses its own options first and exits), so the # spelling here is a make variable; the launcher binary takes `--cmds` verbatim: -# make map mp_boneyard CMDS='spawn assault; hold +attack' -# cargo run -p launcher -- map mp_boneyard --cmds 'spawn assault; hold +attack' +# make map mp_boneyard CMDS='spawn 0; hold +attack' +# cargo run -p launcher -- map mp_boneyard --cmds 'spawn 0; hold +attack' CMDS ?= CMDS_ARG = $(if $(CMDS),--cmds '$(CMDS)',) ZONE ?= @@ -39,18 +39,18 @@ CARGO = cargo # The scripted match. Jump on the spawn pad (open sky) before +forward carries under cover. # Trailing wait lets the automatic reload finish. `quit` flushes `.pftrace`. SCENARIO_ZONE ?= mp_boneyard -SCENARIO_CMDS ?= wait world; spawn assault; force_match_start; hold +attack; bot add 3; wait 3s; press +gostand; hold +forward; wait 5s; wait 4s; quit +SCENARIO_CMDS ?= wait world; spawn 0; force_match_start; hold +attack; bot add 3; wait 3s; press +gostand; hold +forward; wait 5s; wait 4s; quit # Truck 234 roof looking down + 7 bots RPG into the floor. Local does not fire: # `hold +attack` made the truck splash a suicide, which has no killcam. # Bots fire twice so the dump ring contains missiles. It does not replace # SCENARIO_CMDS. Five-number move/tp only — pitch 85 is the test. # Gate: T1/R1/R2/T6/K0/K1/K2/K3/K4/K6/K7/L1/F1/C1/P1/P2. -CHAOS_CMDS ?= wait world; spawn assault; wait 2s; move -1066 1391 7 174 85; wait 1s; bot add 7; bot hold on; wait 3s; bot tp 1 -1066 1391 127 174 85; bot tp 2 -1073 1362 80 174 85; bot tp 3 519 -44 16 61 85; bot tp 4 528 -14 16 61 85; bot tp 5 62 915 80 180 85; bot tp 6 62 900 80 180 85; bot tp 7 80 915 80 180 85; bot give 1 rpg; bot give 2 rpg; bot give 3 rpg; bot give 4 rpg; bot give 5 rpg; bot give 6 rpg; bot give 7 rpg; wait 1s; bot fire all; wait 3s; bot fire all; wait 12s; quit +CHAOS_CMDS ?= wait world; spawn 0; wait 2s; move -1066 1391 7 174 85; wait 1s; bot add 7; bot hold on; wait 3s; bot tp 1 -1066 1391 127 174 85; bot tp 2 -1073 1362 80 174 85; bot tp 3 519 -44 16 61 85; bot tp 4 528 -14 16 61 85; bot tp 5 62 915 80 180 85; bot tp 6 62 900 80 180 85; bot tp 7 80 915 80 180 85; bot give 1 rpg; bot give 2 rpg; bot give 3 rpg; bot give 4 rpg; bot give 5 rpg; bot give 6 rpg; bot give 7 rpg; wait 1s; bot fire all; wait 3s; bot fire all; wait 12s; quit # Live trace run (not a demo). `force_match_start` so holds are not frozen in # warmup. Local `+attack`/`+forward` plus `mouserate` (hold-yaw; not one-shot # `mousemove`). `bot add 16` is the console clamp. Wait 10s, then quit so the # native Perfetto session flushes. -BENCH_LIVE_CMDS ?= wait world; spawn assault; force_match_start; hold +attack; hold +forward; mouserate 10; bot add 16; wait 10s; quit +BENCH_LIVE_CMDS ?= wait world; spawn 0; force_match_start; hold +attack; hold +forward; mouserate 10; bot add 16; wait 10s; quit PERF_OVERHEAD_PAIRS ?= 10 PERF_OVERHEAD_WARMUP_PAIRS ?= 1 PROFILE_BIN = $(ROOT)/target/$(if $(filter dev,$(PROFILE)),debug,$(PROFILE))/iw4l @@ -68,14 +68,14 @@ LIFECYCLE_SWAP_CMDS ?= wait world; wait 2s; disconnect; wait torn; wait 1s; map # `wait world` after rust is scene.spawned; `wait ambient` is MapAmbientBooted. LIFECYCLE_REPLACE_CMDS ?= wait world; wait 2s; map mp_rust; wait world; wait ambient; quit # Match → demo (console `demo`). Records a few ticks first. -LIFECYCLE_PLAY_IN_CMDS ?= wait world; spawn assault; wait 2s; record swap_in; wait 2s; stoprecord; demo swap_in; wait 20s; quit +LIFECYCLE_PLAY_IN_CMDS ?= wait world; spawn 0; wait 2s; record swap_in; wait 2s; stoprecord; demo swap_in; wait 20s; quit # Demo → disconnect → main menu. # Theater occupancy is `theater` events; idle after torn is `cgame_hold`. -LIFECYCLE_DEMO_OUT_CMDS ?= wait world; spawn assault; record swap_out; wait 2s; stoprecord; demo swap_out; wait 8s; disconnect; wait torn; wait 8s; quit +LIFECYCLE_DEMO_OUT_CMDS ?= wait world; spawn 0; record swap_out; wait 2s; stoprecord; demo swap_out; wait 8s; disconnect; wait torn; wait 8s; quit # Demo → map (theater replaced by a live match). # `record` before InGame refuses: sim_cam/cmds_enabled arm on presented Alive; # sync `spawn` blocks until InGame, so the record below always arms. -LIFECYCLE_DEMO_MAP_CMDS ?= wait world; spawn assault; record swap_map; wait 2s; stoprecord; demo swap_map; wait 1s; map mp_rust; wait world; wait 3s; quit +LIFECYCLE_DEMO_MAP_CMDS ?= wait world; spawn 0; record swap_map; wait 2s; stoprecord; demo swap_map; wait 1s; map mp_rust; wait world; wait 3s; quit require-games: @test -n "$(IW4L_GAMES)" || { echo "IW4L_GAMES unset — copy .env.example to .env and set the games root"; exit 1; } @@ -120,7 +120,7 @@ BENCH_DEMO ?= $(DEMO) # The live-map script has to serve both halves: load the map, then stay long # enough that section two has gameplay frames to count. `force_match_start` # skips the 20 s warmup the controls are frozen through (docs/RUN.md). -BENCH_MAP_CMDS ?= wait world; spawn assault; wait ambient; force_match_start; wait 10s; quit +BENCH_MAP_CMDS ?= wait world; spawn 0; wait ambient; force_match_start; wait 10s; quit BENCH_DEMO_CMDS ?= bench: require-games @test -n "$(or $(BENCH_DEMO),$(ZONE),$(ARGS))" || { echo "usage: make bench e.g. make bench demo0011"; echo " make bench ZONE= e.g. make bench ZONE=mp_boneyard"; exit 1; } @@ -218,8 +218,8 @@ lifecycle-demo-map: require-games cd $(ROOT) && IW4L_PERF=1 $(CARGO) run $(PROFILE_ARG) -p launcher -- map mp_boneyard --cmds '$(LIFECYCLE_DEMO_MAP_CMDS)' cd $(ROOT) && $(CARGO) run -p xtask -- live demo-map -HOST_CMDS ?= spawn assault -CLIENT_CMDS ?= spawn assault +HOST_CMDS ?= spawn 0 +CLIENT_CMDS ?= spawn 0 MODE ?= dm export HOST_CMDS CLIENT_CMDS MODE ZONE PROFILE @@ -324,7 +324,7 @@ clean: help: @echo "make map run crates/launcher, zone e.g. mp_boneyard" @echo " make map ZONE=iw5:mp_overwatch (colon cannot be a make goal)" - @echo " add CMDS='spawn assault; hold +attack' to script it" + @echo " add CMDS='spawn 0; hold +attack' to script it" @echo " sync-by-default: map/demo/disconnect/spawn block the FIFO" @echo " until done; trailing '&' opts out (map mp_rust &)" @echo "make play play iw4l-artifacts/demos/.iw4ldemo, then quit" diff --git a/README.md b/README.md index 6678718..fc9e66e 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ tools). ```bash cp .env.example .env # IW4L_GAMES — folder containing the game trees make map mp_boneyard # run a map -make map mp_boneyard CMDS='spawn assault; wait 2s; quit' +make map mp_boneyard CMDS='spawn 0; wait 2s; quit' make help # every recipe ``` diff --git a/crates/approved_tests/src/scenarios/heavy_gameplay_lifecycle.rs b/crates/approved_tests/src/scenarios/heavy_gameplay_lifecycle.rs index 6cbe6ca..118fe1d 100644 --- a/crates/approved_tests/src/scenarios/heavy_gameplay_lifecycle.rs +++ b/crates/approved_tests/src/scenarios/heavy_gameplay_lifecycle.rs @@ -9,7 +9,7 @@ pub const SCENARIO: Scenario = Scenario { name: "heavy_gameplay_lifecycle", gametype: "dm", players: 16, - class: "assault", + class: "0", map_a: "mp_overgrown", map_b: MapB::FromInstalled { game: "iw4" }, scenes: SCENES, diff --git a/crates/console/src/class_menu.rs b/crates/console/src/class_menu.rs index c413b98..5b0da88 100644 --- a/crates/console/src/class_menu.rs +++ b/crates/console/src/class_menu.rs @@ -194,59 +194,57 @@ pub(crate) fn route( .slots .get_mut(selected) .ok_or("Class is unavailable")?; + let mut candidate = slot.clone(); if state.attachments { let mut chosen = if row == ClassEditRow::Primary { - slot.primary_attachments.clone() + candidate.primary_attachments.clone() } else { - slot.secondary_attachments.clone() + candidate.secondary_attachments.clone() }; if value.is_empty() { chosen.clear(); } else if let Some(at) = chosen.iter().position(|name| name == &value) { chosen.remove(at); } else { - if slot.loadout_rules().max_attachments == 1 { + if candidate.loadout_rules().max_attachments == 1 { chosen.clear(); } chosen.push(value); } catalog .check_attachments( - row, - slot.row_value(row), + candidate.row_value(row), &chosen, - slot.loadout_rules(), + candidate.loadout_rules(), ) .map_err(|error| format!("{error:?}"))?; if row == ClassEditRow::Primary { - slot.primary_attachments = chosen; + candidate.primary_attachments = chosen; } else { - slot.secondary_attachments = chosen; + candidate.secondary_attachments = chosen; } } else { - slot.set_row(row, value); - let rules = slot.loadout_rules(); - for (row, weapon, chosen) in [ - ( - ClassEditRow::Primary, - &slot.primary, - &mut slot.primary_attachments, - ), - ( - ClassEditRow::Secondary, - &slot.secondary, - &mut slot.secondary_attachments, - ), + candidate.set_row(row, value); + let rules = candidate.loadout_rules(); + for (weapon, chosen) in [ + (&candidate.primary, &mut candidate.primary_attachments), + (&candidate.secondary, &mut candidate.secondary_attachments), ] { while !chosen.is_empty() - && catalog - .check_attachments(row, weapon, chosen, rules) - .is_err() + && catalog.check_attachments(weapon, chosen, rules).is_err() { chosen.pop(); } } } + catalog.validate_edit(&candidate, row)?; + candidate.lock_reason = catalog.validate_class(&candidate).err(); + if slot.lock_reason.is_none() + && let Some(reason) = &candidate.lock_reason + { + return Err(reason.clone()); + } + *slot = candidate; echo.write(format!( "menu: class {} {} = {}", selected + 1, @@ -271,13 +269,27 @@ pub(crate) fn route( .slots .get_mut(selected) .ok_or("Class is unavailable")?; - let preset = ui::showcase_classes() + let candidate = if let Some(preset) = ui::showcase_classes() .iter() .find(|preset| preset.name == slot.name) - .ok_or("Class has no preset to reset to")?; - let lock = slot.lock_reason.take(); - *slot = ui::ClassSlotState::from_preset(preset); - slot.lock_reason = lock; + { + ui::ClassSlotState::from_preset(preset) + } else { + let registry = catalog + .resolver + .0 + .as_deref() + .ok_or("Weapon catalog is not ready")?; + let mut default = ui::SessionClassStore::from_showcase(0, registry) + .slots + .into_iter() + .nth(selected) + .ok_or("No available default class")?; + default.name.clone_from(&slot.name); + default + }; + catalog.validate_class(&candidate)?; + *slot = candidate; dvars.set("ui_class_name", &slot.name); } "ui_class_rename" => { @@ -329,6 +341,11 @@ pub(crate) fn route( ); } if let Some(slot) = store.slots.get(store.selected) { + if dvars.get("ui_class_status").is_none_or(str::is_empty) + && let Some(reason) = &slot.lock_reason + { + dvars.set("ui_class_status", format!("Class unavailable: {reason}")); + } dvars.set("ui_class_title", &slot.name); dvars.set("ui_class_saved_name", &slot.name); dvars.set("ui_class_index", store.selected.to_string()); diff --git a/crates/console/src/user_settings.rs b/crates/console/src/user_settings.rs index a5224c2..8b588f9 100644 --- a/crates/console/src/user_settings.rs +++ b/crates/console/src/user_settings.rs @@ -184,30 +184,43 @@ pub(crate) fn apply_master_volume( pub(crate) fn sync_player_name( settings: Res, - mut installed: MessageReader, + generation: Res, + has_world: Res, + role: Res, local: Option>, + link: Option>, mut inbox: Option>, mut seq: ResMut, + mut sent: Local>, ) { - let installed_now = installed.read().next().is_some(); - if !settings.is_changed() && !installed_now { + if !has_world.0 || *role == frame::RuntimeRole::Replay { + *sent = None; return; } let (Some(local), Some(inbox)) = (local, inbox.as_deref_mut()) else { return; }; + if let Some(link) = link + && (link.connection.is_none() + || link.assigned_client != Some(local.0) + || !link.has_entered_match()) + { + *sent = None; + return; + } + let name = entity_iw4::pack_client_state_name(&settings.player_name); + let next = (*generation, local.0, name); + if sent.as_ref() == Some(&next) { + return; + } let request_id = seq.allocate(); - if let Err(error) = inbox.push( - local.0, - sim::ClientAction::SetName { - request_id, - name: entity_iw4::pack_client_state_name(&settings.player_name), - }, - ) { + if let Err(error) = inbox.push(local.0, sim::ClientAction::SetName { request_id, name }) { diag::warn!( Console, "name: request_id={request_id} not queued — {error}" ); + } else { + *sent = Some(next); } } diff --git a/crates/hud/src/killfeed.rs b/crates/hud/src/killfeed.rs index 71684ec..1597011 100644 --- a/crates/hud/src/killfeed.rs +++ b/crates/hud/src/killfeed.rs @@ -182,9 +182,9 @@ pub(crate) fn obituary( let payload = obituary.event.payload; let pick = pick_kill_icon(&payload, weapons.as_deref()); let attacker = snapshot_client_name(&presented, payload.attacker_entity_num); - let victim = snapshot_client_name(&presented, payload.number); + let victim = snapshot_client_name(&presented, payload.other_entity_num); let attacker_team = snapshot_client_team(&presented, payload.attacker_entity_num); - let victim_team = snapshot_client_team(&presented, payload.number); + let victim_team = snapshot_client_team(&presented, payload.other_entity_num); let now = milliseconds() as i32; window.lines.push_back(KillfeedLine::Obituary { start_ms: now, @@ -192,7 +192,7 @@ pub(crate) fn obituary( icon_namespace: pick.namespace, attacker, has_attacker: (0..18).contains(&payload.attacker_entity_num) - && payload.attacker_entity_num != payload.number, + && payload.attacker_entity_num != payload.other_entity_num, victim, attacker_team, victim_team, diff --git a/crates/hud/src/menus/mod.rs b/crates/hud/src/menus/mod.rs index f2f2d6f..6cb07af 100644 --- a/crates/hud/src/menus/mod.rs +++ b/crates/hud/src/menus/mod.rs @@ -760,6 +760,18 @@ fn flush_outputs(menus: &mut ScriptMenus, out: &mut MenuOutputs, local: sim::Cli return; }; for (menu, response) in menus.responses.drain(..) { + if menu.eq_ignore_ascii_case("changeclass") + && let Some(slot) = response + .strip_prefix("custom") + .and_then(|n| n.parse::().ok()) + .and_then(|n| n.checked_sub(1)) + .filter(|slot| *slot < sim::match_state::PERSONAL_CLASS_SLOTS) + { + out.exec.write(UiExecCommand { + text: format!("spawn {slot} &"), + }); + continue; + } let (Some(menu_field), Some(response_field)) = ( sim::menu_response_field(&menu), sim::menu_response_field(&response), diff --git a/crates/hud/src/menus/runtime.rs b/crates/hud/src/menus/runtime.rs index 2e8dd99..7a9b034 100644 --- a/crates/hud/src/menus/runtime.rs +++ b/crates/hud/src/menus/runtime.rs @@ -359,6 +359,13 @@ impl Runner<'_, '_> { diag::info!(Ui, "menu open: {}", def.name); let name = def.name.clone(); self.run_events(&name, None, &def.handlers.open); + if self + .menus + .get_mut(&name) + .is_some_and(|open| open.focus.is_none()) + { + self.focus_step(&name, 1, true); + } } pub(crate) fn close(&mut self, name: &str) { diff --git a/crates/hud/src/menus/script.rs b/crates/hud/src/menus/script.rs index abda413..4b9815c 100644 --- a/crates/hud/src/menus/script.rs +++ b/crates/hud/src/menus/script.rs @@ -126,6 +126,7 @@ pub(crate) fn parse(script: &str) -> Vec { "uiscript" => { let script = arg(i + 1).to_ascii_lowercase(); match script.as_str() { + "quit" => (Command::Exec("quit".into()), 2), "openmenuondvar" | "openmenuondvarnot" | "closemenuondvar" | "closemenuondvarnot" => ( Command::MenuOnDvar { diff --git a/crates/master_protocol/src/lib.rs b/crates/master_protocol/src/lib.rs index 51d354d..b1c0444 100644 --- a/crates/master_protocol/src/lib.rs +++ b/crates/master_protocol/src/lib.rs @@ -250,7 +250,7 @@ impl fmt::Display for AdmissionFailure { ), Self::ClassRegistry { host, peer } => write!( f, - "class/loadout table mismatch: host={host:016x} peer={peer:016x}; use the same class setup as the host" + "class catalog mismatch: host={host:016x} peer={peer:016x}; use the same game build as the host" ), } } diff --git a/crates/net/src/client/predict.rs b/crates/net/src/client/predict.rs index 3cea354..6576bf8 100644 --- a/crates/net/src/client/predict.rs +++ b/crates/net/src/client/predict.rs @@ -380,10 +380,19 @@ impl ClientPrediction { if self.history.is_empty() { return; } - self.history.clear(); + self.drop_history(); self.metrics.forced_adopts += 1; } + /// The floor lets stale acks for the dropped commands retire; read as + /// `Broken` they would drop every later window too, for good. + fn drop_history(&mut self) { + if let Some(cmd) = self.last_cmd { + self.replay_floor = Some((CmdSeq(self.next_seq.0.wrapping_sub(1)), cmd)); + } + self.history.clear(); + } + pub fn retire_acks(&mut self, ack: Option) { if !self.armed { return; @@ -417,16 +426,20 @@ impl ClientPrediction { AckMatch::Broken => { outcome.forced_adopt = true; self.metrics.forced_adopts += 1; - self.history.clear(); + self.drop_history(); None } }, + None if self.history.is_empty() => { + self.snapshots_since_ack = 0; + None + } None => { self.snapshots_since_ack = self.snapshots_since_ack.saturating_add(1); - if self.snapshots_since_ack > MAX_UNACKED_SNAPSHOTS && !self.history.is_empty() { + if self.snapshots_since_ack > MAX_UNACKED_SNAPSHOTS { outcome.forced_adopt = true; self.metrics.forced_adopts += 1; - self.history.clear(); + self.drop_history(); self.snapshots_since_ack = 0; } None diff --git a/crates/net/src/client/presentation/entity_event_dispatch.rs b/crates/net/src/client/presentation/entity_event_dispatch.rs index a045d88..e72976c 100644 --- a/crates/net/src/client/presentation/entity_event_dispatch.rs +++ b/crates/net/src/client/presentation/entity_event_dispatch.rs @@ -281,6 +281,8 @@ fn dispatch_entity_events( event: ev.event, payload: EntityEventPayload { number, + other_entity_num: next_state.other_entity_num, + attacker_entity_num: next_state.attacker_entity_num, event_parm: ev.event_parm, origin, surf_type: (ev.event_parm & 0x1f) as u8, @@ -331,7 +333,7 @@ fn dispatch_entity_events( let entity = match resolved { Some(entity) => entity, - None if origin_space_without_centity(record.event) => Entity::PLACEHOLDER, + None if event_without_centity(record.event) => Entity::PLACEHOLDER, None => { target_gaps.raise(NetGapCause::EventNumberHasNoEntity { number }); continue; @@ -457,9 +459,11 @@ fn dispatch_classified( } } -fn origin_space_without_centity(event: EntityEventKind) -> bool { - matches!(entity_event_action(event), Ok(EntityEventAction::PlayFx)) - || event == EntityEventKind::SOUND_ALIAS +fn event_without_centity(event: EntityEventKind) -> bool { + matches!( + entity_event_action(event), + Ok(EntityEventAction::PlayFx | EntityEventAction::Obituary) + ) || event == EntityEventKind::SOUND_ALIAS || event == EntityEventKind::SOUND_ALIAS_AS_MASTER } diff --git a/crates/net/src/lib.rs b/crates/net/src/lib.rs index 59a84c0..38113db 100644 --- a/crates/net/src/lib.rs +++ b/crates/net/src/lib.rs @@ -166,4 +166,4 @@ pub use transport::udp_session::{CommittedAdmission, UdpAuthorityHub, UdpClientL pub use transport::udp_socket::{DEFAULT_RECV_BUDGET_PER_TICK, UdpDatagramSocket, UdpSendError}; pub use transport::wire::{WireError, WireReader, WireWriter}; -pub const PROTOCOL_VERSION: u32 = 81; +pub const PROTOCOL_VERSION: u32 = 82; diff --git a/crates/net/src/transport/meta_wire.rs b/crates/net/src/transport/meta_wire.rs index 47a0374..680c16c 100644 --- a/crates/net/src/transport/meta_wire.rs +++ b/crates/net/src/transport/meta_wire.rs @@ -383,11 +383,19 @@ pub(crate) fn encode_action(out: &mut WireWriter, action: &ClientAction) { request_id, class_id, revision, + loadout, } => { out.put_u8(1); out.put_u32(request_id); out.put_u32(class_id.0); out.put_u32(revision); + for weapon in loadout.weapons { + out.put_u32(weapon); + } + for perk in loadout.perks { + out.put_u32(perk); + } + out.put_u8(loadout.deathstreak); } ClientAction::GiveWeapon { request_id, weapon } => { out.put_u8(6); @@ -506,6 +514,16 @@ pub(crate) fn decode_action(input: &mut WireReader<'_>) -> Result Ok(ClientAction::JoinMatch { request_id: input.get_u32()?, diff --git a/crates/net/src/transport/protocol.rs b/crates/net/src/transport/protocol.rs index dee7cc2..24166a6 100644 --- a/crates/net/src/transport/protocol.rs +++ b/crates/net/src/transport/protocol.rs @@ -154,7 +154,7 @@ impl core::fmt::Display for HandshakeReject { ), Self::ClassRegistry { ours, theirs } => write!( f, - "class table mismatch ours={ours:016x} theirs={theirs:016x}" + "class catalog mismatch ours={ours:016x} theirs={theirs:016x}" ), Self::TickRate { ours, theirs } => { write!(f, "tick rate ours={ours} theirs={theirs}") diff --git a/crates/session/src/lifecycle.rs b/crates/session/src/lifecycle.rs index c3d5866..988423b 100644 --- a/crates/session/src/lifecycle.rs +++ b/crates/session/src/lifecycle.rs @@ -564,10 +564,8 @@ fn connection_notice(reason: &str) -> String { .strip_prefix("handshake rejected: ") .or_else(|| reason.strip_prefix("join admission: ")) .unwrap_or(reason); - let message = if reason.starts_with("class/loadout table mismatch") - || reason.starts_with("class table mismatch") - { - "Your classes/loadouts differ from the host's. Use the same class setup as the host, then reconnect." + let message = if reason.starts_with("class catalog mismatch") { + "Your class rules differ from the host's. Both players need the same game build." } else if reason.starts_with("map content mismatch") { "Your map data differs from the host's. Both players need the same map data to join." } else if reason.starts_with("weapon table mismatch") { diff --git a/crates/session/src/loadout.rs b/crates/session/src/loadout.rs index b275850..82d6a06 100644 --- a/crates/session/src/loadout.rs +++ b/crates/session/src/loadout.rs @@ -79,15 +79,13 @@ pub fn authoritative_class_lock_reason( append_lock_reason(&mut reason, format!("{name}:validated.missing_profile")); } } - for (name, id) in names[2..].iter().zip(&ids[2..]) { + for (slot, (name, id)) in names[2..].iter().zip(&ids[2..]).enumerate() { if *id == 0 { continue; } - if !equipment - .get(*id as usize) - .copied() - .is_some_and(sim::EquipmentRuntimeFacts::is_offhand) - { + if !equipment.get(*id as usize).copied().is_some_and(|facts| { + facts.is_offhand() && matches!((slot, facts.offhand_class), (0, 1 | 4 | 5) | (1, 2 | 3)) + }) { append_lock_reason(&mut reason, format!("{name}:offhand.missing_runtime_facts")); } } @@ -118,52 +116,79 @@ pub fn project_class( combat: &[weapon_iw4::WeaponCombatFacts], equipment: &[sim::EquipmentRuntimeFacts], ) -> AuthoritativeClassProjection { - let rules = asset_game::LoadoutRules::for_class(&row.perks[0]); - let mut lock_reason = None; - let no_attachments = Vec::new(); - let mut ids = [0u32; 4]; - for (slot, id) in ids.iter_mut().enumerate() { - let attachments = row.attachments.get(slot).unwrap_or(&no_attachments); - match resolve_class_weapon(weapons, &row.weapons[slot], attachments, rules) { - Ok(resolved) => *id = resolved, - Err(reason) => append_lock_reason(&mut lock_reason, reason), - } - } - let names = [ - row.weapons[0].as_str(), - row.weapons[1].as_str(), - row.weapons[2].as_str(), - row.weapons[3].as_str(), - ]; - let mut def = sim::ClassDef::primary_secondary(sim::ClassId(class_id), 1, ids[0], ids[1]); - def.lethal = ids[2]; - def.tactical = ids[3]; - if let Some(reason) = authoritative_class_lock_reason(names, ids, combat, equipment) { + let resolved = resolve_personal_class(row, weapons).and_then(|loadout| { + loadout + .definition(sim::ClassId(class_id), 1) + .ok_or_else(|| "class.invalid_definition".to_owned()) + }); + let (mut def, mut lock_reason) = match resolved { + Ok(def) => (def, None), + Err(reason) => ( + sim::ClassDef::primary_secondary(sim::ClassId(class_id), 1, 0, 0), + Some(reason), + ), + }; + let names = row.weapons.each_ref().map(String::as_str); + if let Some(reason) = + authoritative_class_lock_reason(names, def.weapon_slot_ids(), combat, equipment) + { append_lock_reason(&mut lock_reason, reason); } - let perks = [ - row.perks[0].as_str(), - row.perks[1].as_str(), - row.perks[2].as_str(), - ]; - let deathstreak = row.deathstreak.as_str(); - for (slot, perk) in def.perks.iter_mut().zip(perks) { - if perk.is_empty() || perk == "specialty_null" { - continue; - } - match perk_catalog_id(perk) { - Some(id) => *slot = id, - None => append_lock_reason( - &mut lock_reason, - format!("{perk}:perk.unknown_catalog_entry"), - ), - } - } - def.deathstreak = if deathstreak.is_empty() || deathstreak == "specialty_null" { - String::new() - } else { - deathstreak.to_owned() - }; def.locked = lock_reason.is_some(); AuthoritativeClassProjection { def, lock_reason } } + +impl From<&frame::HostClassSlot> for ClassRow { + fn from(slot: &frame::HostClassSlot) -> Self { + Self { + weapons: [ + slot.primary.clone(), + slot.secondary.clone(), + slot.lethal.clone(), + slot.tactical.clone(), + ], + attachments: [ + slot.primary_attachments.clone(), + slot.secondary_attachments.clone(), + ], + perks: slot.perks.clone(), + deathstreak: slot.deathstreak.clone(), + } + } +} + +pub fn resolve_personal_class( + row: &ClassRow, + registry: &WeaponRegistry, +) -> Result { + let mut loadout = sim::PersonalClass::default(); + let rules = asset_game::LoadoutRules::for_class(&row.perks[0]); + for (slot, weapon) in loadout.weapons.iter_mut().enumerate() { + *weapon = resolve_class_weapon( + registry, + &row.weapons[slot], + row.attachments.get(slot).map_or(&[], Vec::as_slice), + rules, + )?; + } + for (slot, name) in row.perks.iter().enumerate() { + if name.is_empty() || name == "specialty_null" { + continue; + } + let id = + perk_catalog_id(name).ok_or_else(|| format!("{name}:perk.unknown_catalog_entry"))?; + if sim::match_state::perk_slot_from_class_catalog(id) != Some(slot) { + return Err(format!("{name}:perk.invalid_slot")); + } + loadout.perks[slot] = id; + } + if !row.deathstreak.is_empty() && row.deathstreak != "specialty_null" { + loadout.deathstreak = sim::match_state::CLASS_CATALOG_DEATHSTREAKS + .iter() + .position(|name| *name == row.deathstreak) + .ok_or_else(|| format!("{}:deathstreak.unknown_catalog_entry", row.deathstreak))? + as u8 + + 1; + } + Ok(loadout) +} diff --git a/crates/session/src/match_apply.rs b/crates/session/src/match_apply.rs index 7c57461..fae1b95 100644 --- a/crates/session/src/match_apply.rs +++ b/crates/session/src/match_apply.rs @@ -1452,7 +1452,7 @@ fn install_clip_and_player( .iter() .enumerate() .map(|(index, preset)| { - let row = class_row(&preset.into()); + let row = ClassRow::from(&preset.into()); project_class(index as u32, &row, weapons, combat, equipment).def }) .collect(); @@ -1510,24 +1510,7 @@ fn install_clip_and_player( pub(crate) fn bootstrap_class_rows(host: Option<&HostClassLoadouts>) -> Vec { let fallback = HostClassLoadouts::default(); let host = host.filter(|h| !h.slots.is_empty()).unwrap_or(&fallback); - host.slots.iter().map(class_row).collect() -} - -fn class_row(slot: &frame::HostClassSlot) -> ClassRow { - ClassRow { - weapons: [ - slot.primary.clone(), - slot.secondary.clone(), - slot.lethal.clone(), - slot.tactical.clone(), - ], - attachments: [ - slot.primary_attachments.clone(), - slot.secondary_attachments.clone(), - ], - perks: slot.perks.clone(), - deathstreak: slot.deathstreak.clone(), - } + host.slots.iter().map(ClassRow::from).collect() } fn install_shocks( diff --git a/crates/sim/src/content.rs b/crates/sim/src/content.rs index 130d9b6..f2f480d 100644 --- a/crates/sim/src/content.rs +++ b/crates/sim/src/content.rs @@ -3,7 +3,7 @@ use crate::spawn::{AuthoredSpawnPoint, MatchBootstrap}; use crate::world::SimBrush; use weapon_iw4::WeaponCombatFacts; -pub const CONTENT_DIGEST_SCHEME: u64 = 15; +pub const CONTENT_DIGEST_SCHEME: u64 = 16; #[derive(Clone, Copy)] struct Digest(u64); @@ -166,26 +166,12 @@ fn hash_weapon_admission(h: &mut Digest, runnable: &[bool], transition_groups: & } } -fn hash_classes(h: &mut Digest, classes: &[ClassDef]) { - h.u64(classes.len() as u64); - for c in classes { - h.u32(c.id.0); - h.u32(c.revision); - h.u32(c.primary); - h.u32(c.secondary); - for id in c.primary_attachments { - h.u32(id); - } - for id in c.secondary_attachments { - h.u32(id); - } - h.u32(c.lethal); - h.u32(c.tactical); - for id in c.perks { - h.u32(id); - } - h.bytes(c.deathstreak.as_bytes()); - h.bool(c.locked); +fn hash_class_catalog(h: &mut Digest) { + for perk in crate::match_state::CLASS_CATALOG_PERKS { + h.bytes(perk.as_bytes()); + } + for streak in crate::match_state::CLASS_CATALOG_DEATHSTREAKS { + h.bytes(streak.as_bytes()); } } @@ -263,7 +249,7 @@ pub fn content_digest_v1( let mut h = Digest::new(); h.u64(CONTENT_DIGEST_SCHEME); hash_combat(&mut h, combat); - hash_classes(&mut h, &bootstrap.classes); + hash_class_catalog(&mut h); hash_spawns(&mut h, &bootstrap.spawns); hash_collision(&mut h, clip_brushes); h.finish() @@ -328,7 +314,7 @@ pub fn content_digest_v2( hash_combat(&mut h, combat); hash_weapon_admission(&mut h, runnable, transition_groups); hash_equipment(&mut h, equipment); - hash_classes(&mut h, &bootstrap.classes); + hash_class_catalog(&mut h); hash_spawns(&mut h, &bootstrap.spawns); hash_collision(&mut h, clip_brushes); hash_script_models(&mut h, script_models); @@ -375,7 +361,7 @@ pub fn content_components_v2( hash_equipment(&mut weapons, equipment); let mut classes = component(b'C'); - hash_classes(&mut classes, &bootstrap.classes); + hash_class_catalog(&mut classes); ContentComponents { map: map.finish(), diff --git a/crates/sim/src/input.rs b/crates/sim/src/input.rs index 192e66a..588d805 100644 --- a/crates/sim/src/input.rs +++ b/crates/sim/src/input.rs @@ -22,6 +22,7 @@ pub enum ClientAction { request_id: ActionRequestId, class_id: ClassId, revision: u32, + loadout: crate::PersonalClass, }, GiveWeapon { diff --git a/crates/sim/src/lib.rs b/crates/sim/src/lib.rs index 4cdc148..593cee5 100644 --- a/crates/sim/src/lib.rs +++ b/crates/sim/src/lib.rs @@ -97,10 +97,10 @@ pub use match_state::{ ConfigurationChangeRejectReason, DroppedItemAmmo, EntityEventPayload, EntityEventRecord, EventAudience, EventRecord, GiveRejectReason, HealthRegenCensus, InputReceipt, ItemPickupRecord, KillcamHud, LoadoutSpec, LocationSelection, MENU_COMMAND_TAIL, - MatchEndReason, MenuCommand, MenuCommandKind, PelletFxRecord, RadarMode, RemoteMissile, - RngDebugMeta, SIM_EVENT_ROSTER, ScriptControls, ScriptDepthOfField, ScriptDvars, ScriptSeat, - SimEvent, SimEventRow, SnapshotMeta, UNRELIABLE_SIM_EVENT_COUNT, ViewEffects, VisionChange, - sim_event_is_reliable, + MatchEndReason, MenuCommand, MenuCommandKind, PelletFxRecord, PersonalClass, RadarMode, + RemoteMissile, RngDebugMeta, SIM_EVENT_ROSTER, ScriptControls, ScriptDepthOfField, ScriptDvars, + ScriptSeat, SimEvent, SimEventRow, SnapshotMeta, UNRELIABLE_SIM_EVENT_COUNT, ViewEffects, + VisionChange, sim_event_is_reliable, }; pub use player_anim_script::{ AnimConditions, AnimScriptCommand, AnimScriptCondition, AnimScriptItem, PlayerAnimScript, diff --git a/crates/sim/src/match_state.rs b/crates/sim/src/match_state.rs index e690ed3..7e02e4a 100644 --- a/crates/sim/src/match_state.rs +++ b/crates/sim/src/match_state.rs @@ -30,8 +30,9 @@ pub use events::{ SIM_EVENT_ROSTER, SimEvent, SimEventRow, UNRELIABLE_SIM_EVENT_COUNT, sim_event_is_reliable, }; pub use loadout::{ - CLASS_CATALOG_PERKS, ClassDef, ClassRejectReason, ConfigurationChangeRejectReason, - GiveRejectReason, LoadoutSpec, + CLASS_CATALOG_DEATHSTREAKS, CLASS_CATALOG_PERKS, ClassDef, ClassRejectReason, + ConfigurationChangeRejectReason, GiveRejectReason, LoadoutSpec, PERSONAL_CLASS_SLOTS, + PersonalClass, }; pub fn class_catalog_perk_name(id: u32) -> Option<&'static str> { CLASS_CATALOG_PERKS diff --git a/crates/sim/src/match_state/loadout.rs b/crates/sim/src/match_state/loadout.rs index 5b318c9..8b35030 100644 --- a/crates/sim/src/match_state/loadout.rs +++ b/crates/sim/src/match_state/loadout.rs @@ -88,6 +88,41 @@ pub struct LoadoutSpec { pub perks: [u32; 3], } +pub const PERSONAL_CLASS_SLOTS: usize = 5; + +pub const CLASS_CATALOG_DEATHSTREAKS: [&str; 4] = [ + "specialty_copycat", + "specialty_combathigh", + "specialty_grenadepulldeath", + "specialty_finalstand", +]; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct PersonalClass { + pub weapons: [u32; 4], + pub perks: [u32; 3], + pub deathstreak: u8, +} + +impl PersonalClass { + pub fn definition(self, id: ClassId, revision: u32) -> Option { + if revision == 0 { + return None; + } + let mut def = ClassDef::primary_secondary(id, revision, self.weapons[0], self.weapons[1]); + def.lethal = self.weapons[2]; + def.tactical = self.weapons[3]; + def.perks = self.perks; + def.deathstreak = if self.deathstreak == 0 { + String::new() + } else { + CLASS_CATALOG_DEATHSTREAKS + .get(self.deathstreak as usize - 1)? + .to_string() + }; + Some(def) + } +} #[derive(Clone, Debug, PartialEq, Eq)] pub struct ClassDef { diff --git a/crates/sim/src/script/host/natives/t5.rs b/crates/sim/src/script/host/natives/t5.rs index 228f378..35ef446 100644 --- a/crates/sim/src/script/host/natives/t5.rs +++ b/crates/sim/src/script/host/natives/t5.rs @@ -403,13 +403,10 @@ fn stats_number(world: &World, reference: &str) -> i32 { stats_cell(world, reference, 0).map_or(0, |n| super::iw4::atoi(&n)) } -fn profile_item(world: &mut World, class: &str, slot: &str) -> i32 { - let Some(def) = profile_class(class).and_then(|id| { - FrameWorld::from_world(world) - .bootstrap_ref() - .class(id) - .cloned() - }) else { +fn profile_item(world: &mut World, client: u32, class: &str, slot: &str) -> i32 { + let Some(def) = profile_class(class) + .and_then(|id| super::super::players::personal_class(world, client, id)) + else { return 0; }; let weapon_slot = |prefix: &str| slot.strip_prefix(prefix).map(|rest| rest.to_owned()); @@ -821,8 +818,8 @@ fn register_script(registry: &mut NativeRegistry) { |world, receiver, args| { let class = string(args, 0)?; let slot = string(args, 1)?.to_ascii_lowercase(); - player_id(world, receiver)?; - Ok(Value::Int(profile_item(world, &class, &slot))) + let client = player_id(world, receiver)?; + Ok(Value::Int(profile_item(world, client, &class, &slot))) }, ); registry.register(Method, "setenemymodel", |world, receiver, args| { diff --git a/crates/sim/src/script/host/players.rs b/crates/sim/src/script/host/players.rs index 8426c4b..9962b00 100644 --- a/crates/sim/src/script/host/players.rs +++ b/crates/sim/src/script/host/players.rs @@ -307,15 +307,14 @@ pub(crate) fn stand_in_for(world: &mut World, slot: usize, weapon: u32) -> Optio frame.weapon_index_by_script_name(stand_ins[slot]) } -fn bridge_class_weapon(world: &mut World, client: u32, slot: usize, weapon: u32) -> u32 { +fn bridge_class_weapon(world: &mut World, client: u32, slot: usize, weapon: u32) { let Some(stand_in) = stand_in_for(world, slot, weapon) else { - return weapon; + return; }; let mut runtime = world.resource_mut::(); let bridge = runtime.weapon_bridge.entry(client).or_default(); bridge.retain(|(from, _)| *from != stand_in); bridge.push((stand_in, weapon)); - stand_in } pub(crate) fn bridged_weapon(world: &World, client: u32, weapon: u32) -> u32 { @@ -336,7 +335,23 @@ pub(crate) fn script_weapon(world: &World, client: u32, weapon: u32) -> u32 { .map_or(weapon, |(stand_in, _)| *stand_in) } +pub(crate) fn personal_class( + world: &World, + client: u32, + class: crate::ClassId, +) -> Option { + world + .resource::() + .personal_classes + .get(&(client, class.0)) + .cloned() +} + pub(crate) fn choose_class(world: &mut World, client: u32, class: &crate::ClassDef) { + world + .resource_mut::() + .personal_classes + .insert((client, class.id.0), class.clone()); let realm = world .resource::() .program @@ -346,17 +361,30 @@ pub(crate) fn choose_class(world: &mut World, client: u32, class: &crate::ClassD .resource_mut::() .weapon_bridge .remove(&client); + bridge_class_weapon(world, client, 0, class.primary); + bridge_class_weapon(world, client, 1, class.secondary); if realm == Some(crate::script::Realm::T5) { - bridge_class_weapon(world, client, 0, class.primary); - bridge_class_weapon(world, client, 1, class.secondary); if let Some(response) = t5_class_response(class.id) { answer_menu(world, client, CLASS_MENU, &response); } return; } + let data = class_profile_data(world, class); + push_answer( + world, + client, + MenuAnswer { + menu: CLASS_MENU.into(), + response: format!("custom{}", class.id.0 as usize % 10 + 1).into(), + data, + }, + ); +} + +fn class_profile_data(world: &mut World, class: &crate::ClassDef) -> Vec<(String, Value)> { let weapons = [ - bridge_class_weapon(world, client, 0, class.primary), - bridge_class_weapon(world, client, 1, class.secondary), + stand_in_for(world, 0, class.primary).unwrap_or(class.primary), + stand_in_for(world, 1, class.secondary).unwrap_or(class.secondary), ]; let index = class.id.0 as usize % 10; let frame = FrameWorld::from_world(world); @@ -417,15 +445,7 @@ pub(crate) fn choose_class(world: &mut World, client: u32, class: &crate::ClassD tactical.strip_suffix("_mp").unwrap_or(&tactical), ); - push_answer( - world, - client, - MenuAnswer { - menu: CLASS_MENU.into(), - response: format!("custom{}", index + 1).into(), - data, - }, - ); + data } fn menu_kind(menu: &str) -> Option { @@ -654,6 +674,13 @@ pub(crate) fn apply_disconnects(world: &mut World) { } pub(crate) fn disconnect_player(world: &mut World, client: u32) { + { + let mut runtime = world.resource_mut::(); + runtime + .personal_classes + .retain(|(owner, _), _| *owner != client); + runtime.weapon_bridge.remove(&client); + } let Some(slot) = world.resource::().players.get(&client).cloned() else { return; }; @@ -716,6 +743,16 @@ pub(crate) fn sync_players(world: &mut World) { } Some(_) => {} None => { + let classes: Vec<_> = world + .resource::() + .personal_classes + .range((client, 0)..=(client, u32::MAX)) + .map(|(_, class)| class.clone()) + .collect(); + let mut data = BTreeMap::new(); + for class in &classes { + data.extend(class_profile_data(world, class)); + } let mut runtime = world.resource_mut::(); let object = match runtime.create_player(client) { Ok(object) => object, @@ -732,7 +769,9 @@ pub(crate) fn sync_players(world: &mut World) { return; } }; - runtime.players.insert(client, PlayerSlot::new(object)); + let mut slot = PlayerSlot::new(object); + slot.data = data; + runtime.players.insert(client, slot); let kept = runtime.restored_pers.remove(&client); let pers = match kept { Some(kept) => super::restart::attach(&mut runtime, kept), diff --git a/crates/sim/src/script/host/restart.rs b/crates/sim/src/script/host/restart.rs index 07f6dc5..1c4a0e2 100644 --- a/crates/sim/src/script/host/restart.rs +++ b/crates/sim/src/script/host/restart.rs @@ -116,6 +116,7 @@ pub(crate) fn restart_level(world: &mut World, tick: crate::Tick) { .collect(); let dvars = runtime.dvars.clone(); let weapon_bridge = runtime.weapon_bridge.clone(); + let personal_classes = runtime.personal_classes.clone(); let next_presence = runtime.next_spawned_presence; let huds: Vec = runtime.hud_slots.keys().copied().collect(); let clients: Vec = runtime.players.keys().copied().collect(); @@ -165,6 +166,7 @@ pub(crate) fn restart_level(world: &mut World, tick: crate::Tick) { let mut runtime = world.resource_mut::(); runtime.dvars = dvars; runtime.weapon_bridge = weapon_bridge; + runtime.personal_classes = personal_classes; runtime.next_spawned_presence = next_presence; runtime.restored_pers = pers; for (name, value) in game { diff --git a/crates/sim/src/script/mod.rs b/crates/sim/src/script/mod.rs index dd9129b..07f8155 100644 --- a/crates/sim/src/script/mod.rs +++ b/crates/sim/src/script/mod.rs @@ -32,7 +32,7 @@ pub(crate) use host::natives::iw4::set_dvar; pub(crate) use host::players::{ answer_join, answer_menu, apply_disconnects, choose_class, choose_default_class, disconnect_player, flashbang, force_death, give_killstreak, is_t5, note_team_answer, - player_damage, script_seats, sync_players, + personal_class, player_damage, script_seats, sync_players, }; pub(crate) use host::presence::sync_presence; pub use host::registry::{Native, NativeRegistry}; diff --git a/crates/sim/src/script/runtime/state.rs b/crates/sim/src/script/runtime/state.rs index ac62273..e346af3 100644 --- a/crates/sim/src/script/runtime/state.rs +++ b/crates/sim/src/script/runtime/state.rs @@ -47,6 +47,7 @@ pub(crate) struct Runtime { pub(crate) engine: host::entities::EngineState, pub(crate) players: BTreeMap, pub(crate) menu_answers: BTreeMap>, + pub(crate) personal_classes: BTreeMap<(u32, u32), crate::ClassDef>, pub(crate) weapon_bridge: BTreeMap>, pub(crate) disconnects: std::collections::BTreeSet, pub(crate) joined: std::collections::BTreeSet, diff --git a/crates/sim/src/spawn.rs b/crates/sim/src/spawn.rs index 063f462..ec34ef9 100644 --- a/crates/sim/src/spawn.rs +++ b/crates/sim/src/spawn.rs @@ -7,7 +7,6 @@ use trace_iw4::Trace; use crate::bullet_collision::{MASK_PLAYER_SOLID, PLAYER_MAXS, PLAYER_MINS}; use crate::identities::MatchRng; -use crate::input::ClassId; use crate::match_state::ClassDef; pub const SPAWN_IDEAL_DIST: f32 = 1600.0; @@ -73,12 +72,6 @@ impl Default for MatchBootstrap { } } -impl MatchBootstrap { - pub fn class(&self, id: ClassId) -> Option<&ClassDef> { - self.classes.iter().find(|c| c.id == id) - } -} - pub fn host_game_mode_kind() -> GameModeKind { std::env::var("IW4L_GAMETYPE") .ok() diff --git a/crates/sim/src/step.rs b/crates/sim/src/step.rs index 79e97e1..d7e7d80 100644 --- a/crates/sim/src/step.rs +++ b/crates/sim/src/step.rs @@ -795,8 +795,9 @@ fn apply_actions(world: &mut FrameWorld, tick: Tick, actions: &[(ClientId, Clien request_id, class_id, revision, + loadout, } => { - apply_select_class(world, tick, *id, request_id, class_id, revision); + apply_select_class(world, tick, *id, request_id, class_id, revision, loadout); } ClientAction::GiveWeapon { request_id, weapon } => { apply_give_weapon(world, tick, *id, request_id, weapon); @@ -1521,16 +1522,11 @@ fn answer_custom_class(world: &mut FrameWorld, id: ClientId, menu: &str, respons else { return false; }; - let Some(def) = world - .bootstrap_ref() - .class(crate::ClassId(slot)) - .filter(|def| !def.locked) - .cloned() - else { - return false; + let Some(def) = crate::script::personal_class(world.ecs(), id.0, crate::ClassId(slot)) else { + return true; }; if validate_class_content(world, &def).is_err() { - return false; + return true; } crate::script::choose_class(world.ecs(), id.0, &def); true @@ -1543,12 +1539,11 @@ fn apply_select_class( request_id: u32, class_id: crate::ClassId, revision: u32, + loadout: crate::PersonalClass, ) { - let accepted = world - .bootstrap_ref() - .class(class_id) - .filter(|def| def.revision == revision) - .cloned(); + let accepted = (class_id.0 < crate::match_state::PERSONAL_CLASS_SLOTS as u32) + .then(|| loadout.definition(class_id, revision)) + .flatten(); let Some(def) = accepted else { diag::info!( Sim, @@ -1570,27 +1565,6 @@ fn apply_select_class( return; }; - if def.locked { - diag::info!( - Sim, - "class select: rejected client={} id={} rev={} reason=locked_content", - id.0, - class_id.0, - revision - ); - world.push_event( - tick, - EventAudience::Client(id), - SimEvent::ClassRejected { - request_id, - class_id, - revision, - reason: crate::ClassRejectReason::LockedContent, - }, - ); - return; - } - if let Err(reason) = validate_class_content(world, &def) { diag::info!( Sim, @@ -1630,11 +1604,13 @@ fn validate_class_content( world: &FrameWorld, def: &crate::ClassDef, ) -> Result<(), crate::ClassRejectReason> { - let table_len = world.weapon_combat_len(); - if table_len == 0 { - return Ok(()); + for (slot, perk) in def.perks.iter().copied().enumerate() { + if perk != 0 && crate::match_state::perk_slot_from_class_catalog(perk) != Some(slot) { + return Err(crate::ClassRejectReason::LockedContent); + } } - + let max_attachments = if def.perks[0] == 12 { 2 } else { 1 }; + let table_len = world.weapon_combat_len(); for id in [def.primary, def.secondary] { if id == 0 { continue; @@ -1645,18 +1621,28 @@ fn validate_class_content( let Some(row) = world.weapon_combat_row(id) else { return Err(crate::ClassRejectReason::UnknownWeaponId); }; - if !row.is_usable() { + if !row.is_usable() + || !world.weapon_runnable(id) + || world + .weapon_setup(id) + .is_some_and(|setup| setup.attachments.len() > max_attachments) + { return Err(crate::ClassRejectReason::LockedContent); } } - for id in [def.lethal, def.tactical] { + for (slot, id) in [def.lethal, def.tactical].into_iter().enumerate() { if id == 0 { continue; } if (id as usize) >= table_len { return Err(crate::ClassRejectReason::UnknownWeaponId); } - if world.offhand_loadout_row(id).is_none() { + let Some(facts) = world.offhand_loadout_row(id) else { + return Err(crate::ClassRejectReason::LockedContent); + }; + if !world.weapon_runnable(id) + || !matches!((slot, facts.offhand_class), (0, 1 | 4 | 5) | (1, 2 | 3)) + { return Err(crate::ClassRejectReason::LockedContent); } } diff --git a/crates/ui/menus/frontend.json b/crates/ui/menus/frontend.json index 655954e..7264c4f 100644 --- a/crates/ui/menus/frontend.json +++ b/crates/ui/menus/frontend.json @@ -428,8 +428,8 @@ 1, 1 ], - "style": 1, - "background": "white", + "style": 0, + "background": "", "back_color": [ 0, 0, @@ -453,24 +453,24 @@ } ], "item_copies": [ - {"index": 27,"fields": {"name": "lobby_member_0","vis_exp": "op 35 s:75695f6c6f6262795f6d656d6265725f305f76697369626c65 op 1","rect": {"x": -272,"y": 28,"w": 216,"h": 20},"background": "","style": 0,"text_key": "","text_exp": "op 38 s:75695f6c6f6262795f6d656d6265725f30 op 1","text_literal": true,"font_enum": 3,"text_scale": 0.375,"text_align_mode": 8,"text_align_x": 8,"fore_color": [1,1,1,1]}}, - {"index": 27,"fields": {"name": "lobby_member_1","vis_exp": "op 35 s:75695f6c6f6262795f6d656d6265725f315f76697369626c65 op 1","rect": {"x": -272,"y": 48,"w": 216,"h": 20},"background": "","style": 0,"text_key": "","text_exp": "op 38 s:75695f6c6f6262795f6d656d6265725f31 op 1","text_literal": true,"font_enum": 3,"text_scale": 0.375,"text_align_mode": 8,"text_align_x": 8,"fore_color": [1,1,1,1]}}, - {"index": 27,"fields": {"name": "lobby_member_2","vis_exp": "op 35 s:75695f6c6f6262795f6d656d6265725f325f76697369626c65 op 1","rect": {"x": -272,"y": 68,"w": 216,"h": 20},"background": "","style": 0,"text_key": "","text_exp": "op 38 s:75695f6c6f6262795f6d656d6265725f32 op 1","text_literal": true,"font_enum": 3,"text_scale": 0.375,"text_align_mode": 8,"text_align_x": 8,"fore_color": [1,1,1,1]}}, - {"index": 27,"fields": {"name": "lobby_member_3","vis_exp": "op 35 s:75695f6c6f6262795f6d656d6265725f335f76697369626c65 op 1","rect": {"x": -272,"y": 88,"w": 216,"h": 20},"background": "","style": 0,"text_key": "","text_exp": "op 38 s:75695f6c6f6262795f6d656d6265725f33 op 1","text_literal": true,"font_enum": 3,"text_scale": 0.375,"text_align_mode": 8,"text_align_x": 8,"fore_color": [1,1,1,1]}}, - {"index": 27,"fields": {"name": "lobby_member_4","vis_exp": "op 35 s:75695f6c6f6262795f6d656d6265725f345f76697369626c65 op 1","rect": {"x": -272,"y": 108,"w": 216,"h": 20},"background": "","style": 0,"text_key": "","text_exp": "op 38 s:75695f6c6f6262795f6d656d6265725f34 op 1","text_literal": true,"font_enum": 3,"text_scale": 0.375,"text_align_mode": 8,"text_align_x": 8,"fore_color": [1,1,1,1]}}, - {"index": 27,"fields": {"name": "lobby_member_5","vis_exp": "op 35 s:75695f6c6f6262795f6d656d6265725f355f76697369626c65 op 1","rect": {"x": -272,"y": 128,"w": 216,"h": 20},"background": "","style": 0,"text_key": "","text_exp": "op 38 s:75695f6c6f6262795f6d656d6265725f35 op 1","text_literal": true,"font_enum": 3,"text_scale": 0.375,"text_align_mode": 8,"text_align_x": 8,"fore_color": [1,1,1,1]}}, - {"index": 27,"fields": {"name": "lobby_member_6","vis_exp": "op 35 s:75695f6c6f6262795f6d656d6265725f365f76697369626c65 op 1","rect": {"x": -272,"y": 148,"w": 216,"h": 20},"background": "","style": 0,"text_key": "","text_exp": "op 38 s:75695f6c6f6262795f6d656d6265725f36 op 1","text_literal": true,"font_enum": 3,"text_scale": 0.375,"text_align_mode": 8,"text_align_x": 8,"fore_color": [1,1,1,1]}}, - {"index": 27,"fields": {"name": "lobby_member_7","vis_exp": "op 35 s:75695f6c6f6262795f6d656d6265725f375f76697369626c65 op 1","rect": {"x": -272,"y": 168,"w": 216,"h": 20},"background": "","style": 0,"text_key": "","text_exp": "op 38 s:75695f6c6f6262795f6d656d6265725f37 op 1","text_literal": true,"font_enum": 3,"text_scale": 0.375,"text_align_mode": 8,"text_align_x": 8,"fore_color": [1,1,1,1]}}, - {"index": 27,"fields": {"name": "lobby_member_8","vis_exp": "op 35 s:75695f6c6f6262795f6d656d6265725f385f76697369626c65 op 1","rect": {"x": -272,"y": 188,"w": 216,"h": 20},"background": "","style": 0,"text_key": "","text_exp": "op 38 s:75695f6c6f6262795f6d656d6265725f38 op 1","text_literal": true,"font_enum": 3,"text_scale": 0.375,"text_align_mode": 8,"text_align_x": 8,"fore_color": [1,1,1,1]}}, - {"index": 27,"fields": {"name": "lobby_member_9","vis_exp": "op 35 s:75695f6c6f6262795f6d656d6265725f395f76697369626c65 op 1","rect": {"x": -272,"y": 208,"w": 216,"h": 20},"background": "","style": 0,"text_key": "","text_exp": "op 38 s:75695f6c6f6262795f6d656d6265725f39 op 1","text_literal": true,"font_enum": 3,"text_scale": 0.375,"text_align_mode": 8,"text_align_x": 8,"fore_color": [1,1,1,1]}}, - {"index": 27,"fields": {"name": "lobby_member_10","vis_exp": "op 35 s:75695f6c6f6262795f6d656d6265725f31305f76697369626c65 op 1","rect": {"x": -272,"y": 228,"w": 216,"h": 20},"background": "","style": 0,"text_key": "","text_exp": "op 38 s:75695f6c6f6262795f6d656d6265725f3130 op 1","text_literal": true,"font_enum": 3,"text_scale": 0.375,"text_align_mode": 8,"text_align_x": 8,"fore_color": [1,1,1,1]}}, - {"index": 27,"fields": {"name": "lobby_member_11","vis_exp": "op 35 s:75695f6c6f6262795f6d656d6265725f31315f76697369626c65 op 1","rect": {"x": -272,"y": 248,"w": 216,"h": 20},"background": "","style": 0,"text_key": "","text_exp": "op 38 s:75695f6c6f6262795f6d656d6265725f3131 op 1","text_literal": true,"font_enum": 3,"text_scale": 0.375,"text_align_mode": 8,"text_align_x": 8,"fore_color": [1,1,1,1]}}, - {"index": 27,"fields": {"name": "lobby_member_12","vis_exp": "op 35 s:75695f6c6f6262795f6d656d6265725f31325f76697369626c65 op 1","rect": {"x": -272,"y": 268,"w": 216,"h": 20},"background": "","style": 0,"text_key": "","text_exp": "op 38 s:75695f6c6f6262795f6d656d6265725f3132 op 1","text_literal": true,"font_enum": 3,"text_scale": 0.375,"text_align_mode": 8,"text_align_x": 8,"fore_color": [1,1,1,1]}}, - {"index": 27,"fields": {"name": "lobby_member_13","vis_exp": "op 35 s:75695f6c6f6262795f6d656d6265725f31335f76697369626c65 op 1","rect": {"x": -272,"y": 288,"w": 216,"h": 20},"background": "","style": 0,"text_key": "","text_exp": "op 38 s:75695f6c6f6262795f6d656d6265725f3133 op 1","text_literal": true,"font_enum": 3,"text_scale": 0.375,"text_align_mode": 8,"text_align_x": 8,"fore_color": [1,1,1,1]}}, - {"index": 27,"fields": {"name": "lobby_member_14","vis_exp": "op 35 s:75695f6c6f6262795f6d656d6265725f31345f76697369626c65 op 1","rect": {"x": -272,"y": 308,"w": 216,"h": 20},"background": "","style": 0,"text_key": "","text_exp": "op 38 s:75695f6c6f6262795f6d656d6265725f3134 op 1","text_literal": true,"font_enum": 3,"text_scale": 0.375,"text_align_mode": 8,"text_align_x": 8,"fore_color": [1,1,1,1]}}, - {"index": 27,"fields": {"name": "lobby_member_15","vis_exp": "op 35 s:75695f6c6f6262795f6d656d6265725f31355f76697369626c65 op 1","rect": {"x": -272,"y": 328,"w": 216,"h": 20},"background": "","style": 0,"text_key": "","text_exp": "op 38 s:75695f6c6f6262795f6d656d6265725f3135 op 1","text_literal": true,"font_enum": 3,"text_scale": 0.375,"text_align_mode": 8,"text_align_x": 8,"fore_color": [1,1,1,1]}}, - {"index": 27,"fields": {"name": "lobby_member_16","vis_exp": "op 35 s:75695f6c6f6262795f6d656d6265725f31365f76697369626c65 op 1","rect": {"x": -272,"y": 348,"w": 216,"h": 20},"background": "","style": 0,"text_key": "","text_exp": "op 38 s:75695f6c6f6262795f6d656d6265725f3136 op 1","text_literal": true,"font_enum": 3,"text_scale": 0.375,"text_align_mode": 8,"text_align_x": 8,"fore_color": [1,1,1,1]}}, - {"index": 27,"fields": {"name": "lobby_member_17","vis_exp": "op 35 s:75695f6c6f6262795f6d656d6265725f31375f76697369626c65 op 1","rect": {"x": -272,"y": 368,"w": 216,"h": 20},"background": "","style": 0,"text_key": "","text_exp": "op 38 s:75695f6c6f6262795f6d656d6265725f3137 op 1","text_literal": true,"font_enum": 3,"text_scale": 0.375,"text_align_mode": 8,"text_align_x": 8,"fore_color": [1,1,1,1]}}, + {"index": 27,"fields": {"name": "lobby_member_0","vis_exp": "op 35 s:75695f6c6f6262795f6d656d6265725f305f76697369626c65 op 1","rect": {"x": -272,"y": 28,"w": 272,"h": 20},"background": "white","style": 1,"back_color": [0,0,0,0.25],"text_key": "","text_exp": "op 38 s:75695f6c6f6262795f6d656d6265725f30 op 1","text_literal": true,"font_enum": 3,"text_scale": 0.375,"text_align_mode": 8,"text_align_x": 8,"fore_color": [1,1,1,1]}}, + {"index": 27,"fields": {"name": "lobby_member_1","vis_exp": "op 35 s:75695f6c6f6262795f6d656d6265725f315f76697369626c65 op 1","rect": {"x": -272,"y": 48,"w": 272,"h": 20},"background": "white","style": 1,"back_color": [0,0,0,0.25],"text_key": "","text_exp": "op 38 s:75695f6c6f6262795f6d656d6265725f31 op 1","text_literal": true,"font_enum": 3,"text_scale": 0.375,"text_align_mode": 8,"text_align_x": 8,"fore_color": [1,1,1,1]}}, + {"index": 27,"fields": {"name": "lobby_member_2","vis_exp": "op 35 s:75695f6c6f6262795f6d656d6265725f325f76697369626c65 op 1","rect": {"x": -272,"y": 68,"w": 272,"h": 20},"background": "white","style": 1,"back_color": [0,0,0,0.25],"text_key": "","text_exp": "op 38 s:75695f6c6f6262795f6d656d6265725f32 op 1","text_literal": true,"font_enum": 3,"text_scale": 0.375,"text_align_mode": 8,"text_align_x": 8,"fore_color": [1,1,1,1]}}, + {"index": 27,"fields": {"name": "lobby_member_3","vis_exp": "op 35 s:75695f6c6f6262795f6d656d6265725f335f76697369626c65 op 1","rect": {"x": -272,"y": 88,"w": 272,"h": 20},"background": "white","style": 1,"back_color": [0,0,0,0.25],"text_key": "","text_exp": "op 38 s:75695f6c6f6262795f6d656d6265725f33 op 1","text_literal": true,"font_enum": 3,"text_scale": 0.375,"text_align_mode": 8,"text_align_x": 8,"fore_color": [1,1,1,1]}}, + {"index": 27,"fields": {"name": "lobby_member_4","vis_exp": "op 35 s:75695f6c6f6262795f6d656d6265725f345f76697369626c65 op 1","rect": {"x": -272,"y": 108,"w": 272,"h": 20},"background": "white","style": 1,"back_color": [0,0,0,0.25],"text_key": "","text_exp": "op 38 s:75695f6c6f6262795f6d656d6265725f34 op 1","text_literal": true,"font_enum": 3,"text_scale": 0.375,"text_align_mode": 8,"text_align_x": 8,"fore_color": [1,1,1,1]}}, + {"index": 27,"fields": {"name": "lobby_member_5","vis_exp": "op 35 s:75695f6c6f6262795f6d656d6265725f355f76697369626c65 op 1","rect": {"x": -272,"y": 128,"w": 272,"h": 20},"background": "white","style": 1,"back_color": [0,0,0,0.25],"text_key": "","text_exp": "op 38 s:75695f6c6f6262795f6d656d6265725f35 op 1","text_literal": true,"font_enum": 3,"text_scale": 0.375,"text_align_mode": 8,"text_align_x": 8,"fore_color": [1,1,1,1]}}, + {"index": 27,"fields": {"name": "lobby_member_6","vis_exp": "op 35 s:75695f6c6f6262795f6d656d6265725f365f76697369626c65 op 1","rect": {"x": -272,"y": 148,"w": 272,"h": 20},"background": "white","style": 1,"back_color": [0,0,0,0.25],"text_key": "","text_exp": "op 38 s:75695f6c6f6262795f6d656d6265725f36 op 1","text_literal": true,"font_enum": 3,"text_scale": 0.375,"text_align_mode": 8,"text_align_x": 8,"fore_color": [1,1,1,1]}}, + {"index": 27,"fields": {"name": "lobby_member_7","vis_exp": "op 35 s:75695f6c6f6262795f6d656d6265725f375f76697369626c65 op 1","rect": {"x": -272,"y": 168,"w": 272,"h": 20},"background": "white","style": 1,"back_color": [0,0,0,0.25],"text_key": "","text_exp": "op 38 s:75695f6c6f6262795f6d656d6265725f37 op 1","text_literal": true,"font_enum": 3,"text_scale": 0.375,"text_align_mode": 8,"text_align_x": 8,"fore_color": [1,1,1,1]}}, + {"index": 27,"fields": {"name": "lobby_member_8","vis_exp": "op 35 s:75695f6c6f6262795f6d656d6265725f385f76697369626c65 op 1","rect": {"x": -272,"y": 188,"w": 272,"h": 20},"background": "white","style": 1,"back_color": [0,0,0,0.25],"text_key": "","text_exp": "op 38 s:75695f6c6f6262795f6d656d6265725f38 op 1","text_literal": true,"font_enum": 3,"text_scale": 0.375,"text_align_mode": 8,"text_align_x": 8,"fore_color": [1,1,1,1]}}, + {"index": 27,"fields": {"name": "lobby_member_9","vis_exp": "op 35 s:75695f6c6f6262795f6d656d6265725f395f76697369626c65 op 1","rect": {"x": -272,"y": 208,"w": 272,"h": 20},"background": "white","style": 1,"back_color": [0,0,0,0.25],"text_key": "","text_exp": "op 38 s:75695f6c6f6262795f6d656d6265725f39 op 1","text_literal": true,"font_enum": 3,"text_scale": 0.375,"text_align_mode": 8,"text_align_x": 8,"fore_color": [1,1,1,1]}}, + {"index": 27,"fields": {"name": "lobby_member_10","vis_exp": "op 35 s:75695f6c6f6262795f6d656d6265725f31305f76697369626c65 op 1","rect": {"x": -272,"y": 228,"w": 272,"h": 20},"background": "white","style": 1,"back_color": [0,0,0,0.25],"text_key": "","text_exp": "op 38 s:75695f6c6f6262795f6d656d6265725f3130 op 1","text_literal": true,"font_enum": 3,"text_scale": 0.375,"text_align_mode": 8,"text_align_x": 8,"fore_color": [1,1,1,1]}}, + {"index": 27,"fields": {"name": "lobby_member_11","vis_exp": "op 35 s:75695f6c6f6262795f6d656d6265725f31315f76697369626c65 op 1","rect": {"x": -272,"y": 248,"w": 272,"h": 20},"background": "white","style": 1,"back_color": [0,0,0,0.25],"text_key": "","text_exp": "op 38 s:75695f6c6f6262795f6d656d6265725f3131 op 1","text_literal": true,"font_enum": 3,"text_scale": 0.375,"text_align_mode": 8,"text_align_x": 8,"fore_color": [1,1,1,1]}}, + {"index": 27,"fields": {"name": "lobby_member_12","vis_exp": "op 35 s:75695f6c6f6262795f6d656d6265725f31325f76697369626c65 op 1","rect": {"x": -272,"y": 268,"w": 272,"h": 20},"background": "white","style": 1,"back_color": [0,0,0,0.25],"text_key": "","text_exp": "op 38 s:75695f6c6f6262795f6d656d6265725f3132 op 1","text_literal": true,"font_enum": 3,"text_scale": 0.375,"text_align_mode": 8,"text_align_x": 8,"fore_color": [1,1,1,1]}}, + {"index": 27,"fields": {"name": "lobby_member_13","vis_exp": "op 35 s:75695f6c6f6262795f6d656d6265725f31335f76697369626c65 op 1","rect": {"x": -272,"y": 288,"w": 272,"h": 20},"background": "white","style": 1,"back_color": [0,0,0,0.25],"text_key": "","text_exp": "op 38 s:75695f6c6f6262795f6d656d6265725f3133 op 1","text_literal": true,"font_enum": 3,"text_scale": 0.375,"text_align_mode": 8,"text_align_x": 8,"fore_color": [1,1,1,1]}}, + {"index": 27,"fields": {"name": "lobby_member_14","vis_exp": "op 35 s:75695f6c6f6262795f6d656d6265725f31345f76697369626c65 op 1","rect": {"x": -272,"y": 308,"w": 272,"h": 20},"background": "white","style": 1,"back_color": [0,0,0,0.25],"text_key": "","text_exp": "op 38 s:75695f6c6f6262795f6d656d6265725f3134 op 1","text_literal": true,"font_enum": 3,"text_scale": 0.375,"text_align_mode": 8,"text_align_x": 8,"fore_color": [1,1,1,1]}}, + {"index": 27,"fields": {"name": "lobby_member_15","vis_exp": "op 35 s:75695f6c6f6262795f6d656d6265725f31355f76697369626c65 op 1","rect": {"x": -272,"y": 328,"w": 272,"h": 20},"background": "white","style": 1,"back_color": [0,0,0,0.25],"text_key": "","text_exp": "op 38 s:75695f6c6f6262795f6d656d6265725f3135 op 1","text_literal": true,"font_enum": 3,"text_scale": 0.375,"text_align_mode": 8,"text_align_x": 8,"fore_color": [1,1,1,1]}}, + {"index": 27,"fields": {"name": "lobby_member_16","vis_exp": "op 35 s:75695f6c6f6262795f6d656d6265725f31365f76697369626c65 op 1","rect": {"x": -272,"y": 348,"w": 272,"h": 20},"background": "white","style": 1,"back_color": [0,0,0,0.25],"text_key": "","text_exp": "op 38 s:75695f6c6f6262795f6d656d6265725f3136 op 1","text_literal": true,"font_enum": 3,"text_scale": 0.375,"text_align_mode": 8,"text_align_x": 8,"fore_color": [1,1,1,1]}}, + {"index": 27,"fields": {"name": "lobby_member_17","vis_exp": "op 35 s:75695f6c6f6262795f6d656d6265725f31375f76697369626c65 op 1","rect": {"x": -272,"y": 368,"w": 272,"h": 20},"background": "white","style": 1,"back_color": [0,0,0,0.25],"text_key": "","text_exp": "op 38 s:75695f6c6f6262795f6d656d6265725f3137 op 1","text_literal": true,"font_enum": 3,"text_scale": 0.375,"text_align_mode": 8,"text_align_x": 8,"fore_color": [1,1,1,1]}}, { "index": 27, "fields": { diff --git a/crates/ui/src/classes/equip_txn.rs b/crates/ui/src/classes/equip_txn.rs index 5f244db..cba3f72 100644 --- a/crates/ui/src/classes/equip_txn.rs +++ b/crates/ui/src/classes/equip_txn.rs @@ -16,6 +16,7 @@ pub struct EquipTxnWatch { pub fn apply_pending_class_equip( mut pending: ResMut, store: Res, + weapons: Option>, mut actions: ResMut, mut watch: ResMut, mut phase: ResMut, @@ -38,17 +39,33 @@ pub fn apply_pending_class_equip( "class equip: dropped request_id={} — {reason}", req.request_id ); + reject_class_equip(&mut phase, &mut status, req.request_id, reason); return; } let index = req.class_index; - if store.slots.get(index).is_none() { - diag::info!( - Ui, - "class equip: class index {index} out of range (request_id={})", - req.request_id + let Some(slot) = store.slots.get(index) else { + reject_class_equip( + &mut phase, + &mut status, + req.request_id, + "Class is unavailable".to_owned(), ); return; - } + }; + let resolved = weapons + .as_ref() + .ok_or_else(|| "Weapon catalog is not ready".to_owned()) + .and_then(|weapons| { + let row = session::ClassRow::from(&frame::HostClassSlot::from(slot)); + session::loadout::resolve_personal_class(&row, &weapons.0) + }); + let loadout = match resolved { + Ok(loadout) => loadout, + Err(reason) => { + reject_class_equip(&mut phase, &mut status, req.request_id, reason); + return; + } + }; if let Err(error) = actions.push( local.0, @@ -56,6 +73,7 @@ pub fn apply_pending_class_equip( request_id: req.request_id, class_id: ClassId(index as u32), revision: 1, + loadout, }, ) { diag::info!( @@ -133,6 +151,7 @@ pub fn sync_class_change_allowed( } pub fn resolve_class_equip_transaction( + mut store: ResMut, mut phase: ResMut, mut overlay: ResMut, mut status: ResMut, @@ -164,7 +183,7 @@ pub fn resolve_class_equip_transaction( SimEvent::ClassAccepted { request_id: rid, .. } if rid == request_id => { - if accept_class_equip(&mut phase, &mut overlay, request_id) { + if accept_class_equip(&mut store, &mut phase, &mut overlay, request_id) { diag::info!( Ui, "class select: reliable Accept request_id={request_id} (overlay closed)" @@ -195,6 +214,7 @@ pub(crate) fn register_equip_systems(app: &mut App) { sync_class_change_allowed, apply_pending_class_equip, ) + .chain() .in_set(ClientSet::Present), ) .add_systems( @@ -209,10 +229,17 @@ fn consume_class_select_handoff( mut handoff: ResMut, mut catalog: ResMut, mut store: ResMut, + weapons: Option>, ) { if !handoff.pending { return; } + if catalog.resolver.0.is_none() { + let Some(weapons) = weapons else { + return; + }; + *catalog = crate::ClassLoadoutCatalog::from_weapon_registry(weapons.0.clone()); + } catalog.revision = catalog.revision.wrapping_add(1); catalog.primary = std::mem::take(&mut handoff.primary); catalog.secondary = std::mem::take(&mut handoff.secondary); @@ -251,6 +278,7 @@ fn publish_signon_class_status( } fn reset_equip_transaction( + mut store: ResMut, mut torn: MessageReader, mut watch: ResMut, mut pending: ResMut, @@ -261,6 +289,7 @@ fn reset_equip_transaction( return; } torn.clear(); + store.equipped = None; *watch = EquipTxnWatch::default(); pending.0 = None; *phase = ClassSelectPhase::default(); diff --git a/crates/ui/src/classes/select.rs b/crates/ui/src/classes/select.rs index 2e5a24a..40c1a45 100644 --- a/crates/ui/src/classes/select.rs +++ b/crates/ui/src/classes/select.rs @@ -151,7 +151,7 @@ pub fn commit_class_equip( } let request_id = seq.allocate(); highlight.0 = index; - store.commit_equip(index); + store.selected = index; pending.0 = Some(ClassEquipRequest { request_id, class_index: index, @@ -165,6 +165,7 @@ pub fn commit_class_equip( } pub fn accept_class_equip( + store: &mut SessionClassStore, phase: &mut ClassSelectPhase, overlay: &mut ClassSelectOverlayOpen, request_id: u32, @@ -172,8 +173,9 @@ pub fn accept_class_equip( match *phase { ClassSelectPhase::Pending { request_id: pending, - .. + class_index, } if pending == request_id => { + store.equipped = Some(class_index); *phase = ClassSelectPhase::Interactive; overlay.0 = false; true diff --git a/crates/ui/src/classes/setup.rs b/crates/ui/src/classes/setup.rs index 29dad9d..0effdeb 100644 --- a/crates/ui/src/classes/setup.rs +++ b/crates/ui/src/classes/setup.rs @@ -99,16 +99,6 @@ pub struct ClassLoadoutCatalog { impl Default for ClassLoadoutCatalog { fn default() -> Self { - let guns = |row| { - cac_roster(row) - .iter() - .map(|value| frame::CacWeaponOffer { - key: format!("iw4:weapon/{value}"), - item_group: asset_game::iw4_fallback_item_group(value).map(str::to_owned), - attachments: Vec::new(), - }) - .collect() - }; let collect = |row| { cac_roster(row) .iter() @@ -117,10 +107,10 @@ impl Default for ClassLoadoutCatalog { }; Self { revision: 0, - primary: guns(ClassEditRow::Primary), - secondary: guns(ClassEditRow::Secondary), - lethal: guns(ClassEditRow::Lethal), - tactical: guns(ClassEditRow::Tactical), + primary: Vec::new(), + secondary: Vec::new(), + lethal: Vec::new(), + tactical: Vec::new(), perks: [ collect(ClassEditRow::Perk1), collect(ClassEditRow::Perk2), @@ -137,14 +127,7 @@ impl Default for ClassLoadoutCatalog { impl ClassLoadoutCatalog { pub fn from_weapon_registry(registry: std::sync::Arc) -> Self { let families = registry.weapon_families(); - if families.families().is_empty() { - return Self::default(); - } let mut catalog = Self::default(); - catalog.primary.clear(); - catalog.secondary.clear(); - catalog.lethal.clear(); - catalog.tactical.clear(); for family in families.offered() { let key = family.key.asset_key(); catalog.previews.insert( @@ -346,35 +329,61 @@ impl ClassLoadoutCatalog { .unwrap_or(&[]) } + pub fn validate_class(&self, slot: &ClassSlotState) -> Result<(), String> { + let registry = self + .resolver + .0 + .as_deref() + .ok_or("Weapon catalog is not ready")?; + let row = session::ClassRow::from(&frame::HostClassSlot::from(slot)); + session::loadout::resolve_personal_class(&row, registry).map(|_| ()) + } + + pub fn validate_edit(&self, slot: &ClassSlotState, row: ClassEditRow) -> Result<(), String> { + let registry = self + .resolver + .0 + .as_deref() + .ok_or("Weapon catalog is not ready")?; + let attachments = match row { + ClassEditRow::Primary => slot.primary_attachments.as_slice(), + ClassEditRow::Secondary => slot.secondary_attachments.as_slice(), + _ => &[], + }; + if Self::uses_categories(row) { + session::resolve_class_weapon( + registry, + slot.row_value(row), + attachments, + slot.loadout_rules(), + )?; + } else if !self + .options(row) + .iter() + .any(|value| value == slot.row_value(row)) + { + return Err("Perk is unavailable".into()); + } + Ok(()) + } + pub fn check_attachments( &self, - row: ClassEditRow, weapon: &str, attachments: &[String], rules: asset_game::LoadoutRules, ) -> Result<(), asset_game::ConfigurationRefusal> { - if let Some(registry) = self.resolver.0.as_deref() { - let family = asset_game::FamilyKey::parse(weapon).ok_or_else(|| { - asset_game::ConfigurationRefusal::UnknownFamily(weapon.to_owned()) - })?; - return registry - .resolve_configuration( - &asset_game::WeaponSelection::with(family, attachments), - rules, - ) - .map(|_| ()); - } - let offered = self.attachments(row, weapon); - if let Some(name) = attachments.iter().find(|name| !offered.contains(name)) { - return Err(asset_game::ConfigurationRefusal::NotOffered(name.clone())); - } - if attachments.len() > rules.max_attachments { - return Err(asset_game::ConfigurationRefusal::RuleRestricted(format!( - "at most {} attachments", - rules.max_attachments - ))); - } - Ok(()) + let registry = self.resolver.0.as_deref().ok_or_else(|| { + asset_game::ConfigurationRefusal::MissingContent("Weapon catalog is not ready".into()) + })?; + let family = asset_game::FamilyKey::parse(weapon) + .ok_or_else(|| asset_game::ConfigurationRefusal::UnknownFamily(weapon.to_owned()))?; + registry + .resolve_configuration( + &asset_game::WeaponSelection::with(family, attachments), + rules, + ) + .map(|_| ()) } } @@ -424,6 +433,23 @@ pub struct ClassSlotState { } impl ClassSlotState { + pub fn from_host_slot(slot: &frame::HostClassSlot) -> Self { + Self { + name: slot.name.clone(), + primary: slot.primary.clone(), + secondary: slot.secondary.clone(), + primary_attachments: slot.primary_attachments.clone(), + secondary_attachments: slot.secondary_attachments.clone(), + lethal: slot.lethal.clone(), + tactical: slot.tactical.clone(), + perk1: slot.perks[0].clone(), + perk2: slot.perks[1].clone(), + perk3: slot.perks[2].clone(), + deathstreak: slot.deathstreak.clone(), + lock_reason: None, + } + } + pub fn from_preset(preset: &crate::ClassPreset) -> Self { let perk = |i: usize| match preset.perks[i] { "" => NO_PERK.to_owned(), diff --git a/crates/ui/src/classes/store.rs b/crates/ui/src/classes/store.rs index dc0a9c6..19c0ccd 100644 --- a/crates/ui/src/classes/store.rs +++ b/crates/ui/src/classes/store.rs @@ -2,8 +2,9 @@ use bevy::prelude::*; use crate::classes::setup::ClassSlotState; use frame::{HostClassLoadouts, HostClassSlot}; +use sim::match_state::PERSONAL_CLASS_SLOTS; -#[derive(Resource, Clone, Debug, PartialEq, Eq)] +#[derive(Resource, Clone, Debug, Default, PartialEq, Eq)] pub struct SessionClassStore { pub slots: Vec, @@ -11,34 +12,61 @@ pub struct SessionClassStore { pub selected: usize, } -impl Default for SessionClassStore { - fn default() -> Self { - let seed = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_or(0, |elapsed| elapsed.as_nanos() as u64); - Self::from_showcase(seed) - } -} - impl SessionClassStore { - pub fn from_showcase(seed: u64) -> Self { - Self { - slots: frame::pick_showcase(seed, 5) + pub fn from_showcase(seed: u64, registry: &asset_game::WeaponRegistry) -> Self { + let combat = session::combat_table::from_registry(registry, None); + let equipment = session::combat_table::equipment_from_registry(registry); + let available = |slot: &HostClassSlot| { + let row = session::ClassRow::from(slot); + !session::project_class(0, &row, registry, &combat, &equipment) + .def + .locked + }; + let mut slots: Vec = + frame::pick_showcase(seed, frame::showcase_classes().len()) .into_iter() - .map(ClassSlotState::from_preset) - .collect(), + .map(HostClassSlot::from) + .filter(&available) + .take(PERSONAL_CLASS_SLOTS) + .collect(); + if slots.len() < PERSONAL_CLASS_SLOTS { + for primary in ["m4", "mp5k", "m16", "ak47", "rpd"] { + let slot = HostClassSlot { + name: format!("custom_{}", slots.len() + 1), + primary: format!("iw4:weapon/{primary}_mp"), + primary_attachments: Vec::new(), + secondary: "iw4:weapon/usp_mp".into(), + secondary_attachments: Vec::new(), + lethal: "iw4:weapon/frag_grenade_mp".into(), + tactical: "iw4:weapon/flash_grenade_mp".into(), + perks: [ + "specialty_fastreload".into(), + "specialty_bulletdamage".into(), + "specialty_bulletaccuracy".into(), + ], + deathstreak: "specialty_copycat".into(), + }; + if available(&slot) { + slots.push(slot); + } + if slots.len() == PERSONAL_CLASS_SLOTS { + break; + } + } + } + if !slots.is_empty() { + let available = slots.clone(); + while slots.len() < PERSONAL_CLASS_SLOTS { + slots.push(available[slots.len() % available.len()].clone()); + } + } + Self { + slots: slots.iter().map(ClassSlotState::from_host_slot).collect(), equipped: None, selected: 0, } } - pub fn commit_equip(&mut self, selected: usize) { - if selected < self.slots.len() { - self.selected = selected; - self.equipped = Some(selected); - } - } - pub fn equipped_slot(&self) -> Option<&ClassSlotState> { self.equipped.and_then(|i| self.slots.get(i)) } @@ -150,6 +178,9 @@ fn decode_slots(text: &str) -> Option> { else { return None; }; + if slots.len() == PERSONAL_CLASS_SLOTS { + return None; + } slots.push(ClassSlotState { name: (*name).to_owned(), primary: (*primary).to_owned(), @@ -172,6 +203,7 @@ pub(crate) fn load_class_store( identity: Option>, mut file: ResMut, mut store: ResMut, + catalog: Res, ) { if file.loaded { return; @@ -182,17 +214,30 @@ pub(crate) fn load_class_store( if identity.artifacts.as_os_str().is_empty() { return; } + let Some(registry) = catalog.resolver.0.as_deref() else { + return; + }; let path = identity.artifacts.join("profile").join("classes.txt"); file.loaded = true; + let generate = || { + let seed = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |elapsed| elapsed.as_nanos() as u64); + SessionClassStore::from_showcase(seed, registry) + }; match std::fs::read_to_string(&path) { Ok(text) => match decode_slots(&text) { Some(slots) => { diag::info!(Ui, "classes: {} read from {}", slots.len(), path.display()); file.written = Some(encode_slots(&slots)); store.slots = slots; + for slot in &mut store.slots { + slot.lock_reason = catalog.validate_class(slot).err(); + } store.selected = store.selected.min(store.slots.len().saturating_sub(1)); } None => { + *store = generate(); diag::warn!( Ui, "classes: {} is not a class file this build reads; file preserved", @@ -201,8 +246,11 @@ pub(crate) fn load_class_store( return; } }, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + *store = generate(); + } Err(error) => { + *store = generate(); diag::warn!( Ui, "classes: cannot read {}: {error}; file preserved", @@ -234,8 +282,13 @@ fn write_class_file(path: &std::path::Path, contents: &str) -> std::io::Result<( result } -pub(crate) fn save_class_store(store: Res, mut file: ResMut) { +pub(crate) fn save_class_store( + store: Res, + catalog: Res, + mut file: ResMut, +) { if !file.loaded + || store.slots.is_empty() || file .retry_at .is_some_and(|at| std::time::Instant::now() < at) @@ -252,6 +305,20 @@ pub(crate) fn save_class_store(store: Res, mut file: ResMut { file.written = Some(contents); diff --git a/docs/DUO.md b/docs/DUO.md index 668cb3e..c609742 100644 --- a/docs/DUO.md +++ b/docs/DUO.md @@ -7,7 +7,7 @@ windows. It uses `IW4L_GAMES` and the `IW4L_MASTER_*` connection settings from ```bash make duo make duo ZONE=iw4:mp_afghan MODE=dm -make duo HOST_CMDS='spawn assault; wait 20s; dump host' CLIENT_CMDS='spawn assault' +make duo HOST_CMDS='spawn 0; wait 20s; dump host' CLIENT_CMDS='spawn 0' ``` Every run creates a fresh public lobby on the master. The host's actual lobby @@ -17,7 +17,7 @@ membership. There is no browser-row selection or fixed lobby startup delay. GSC still owns classes, countdown, scoring, death and respawn. `HOST_CMDS` and `CLIENT_CMDS` run after `wait world`; both default to -`spawn assault`. During the run, type into the terminal: +`spawn 0`. During the run, type into the terminal: ```text host dump host diff --git a/docs/RUN.md b/docs/RUN.md index f3b71d2..a341669 100644 --- a/docs/RUN.md +++ b/docs/RUN.md @@ -5,7 +5,7 @@ Two players through the master: [`make duo`](DUO.md). ```bash set -a; . ./.env; set +a # IW4L_GAMES; DISPLAY=:0 if the session has none -make map mp_boneyard CMDS='spawn assault; wait 2s; quit' +make map mp_boneyard CMDS='spawn 0; wait 2s; quit' cargo run --profile play -p launcher -- map iw5:mp_overwatch --cmds '…' ``` @@ -18,7 +18,7 @@ scenario`, `chaos`, `bench` ([`BENCH.md`](BENCH.md)), `bench-live`, `lifecycle-* **GSC controls the start freeze.** `freezecontrols` constrains authority movement; snapshots carry the same constraints into client prediction and command replay. Wait for the loaded mode's countdown, or use the debug command: -`wait world; spawn assault; force_match_start; wait 1s; …`. +`wait world; spawn 0; force_match_start; wait 1s; …`. Firing, hit and decal probes must start after the countdown releases the player. **Sync by default.** Ritual `wait`s are unnecessary: a command holds the FIFO @@ -59,7 +59,8 @@ received plus its own presented state; `demo LATEST` plays it back. * `look` without `LookState` only writes `ps.viewangles` — no aiming; * `give` takes a namespace: `give t5:weapon/psg1_acog`, `give iw5:weapon/msr`. * custom classes live in `iw4l-artifacts/profile/classes.txt` (one tab-separated - row per class); delete it to get the presets back. + row per class); `spawn 0` selects the first slot. Delete the file to generate + five available classes again. Video settings include brightness (50–150%, neutral 100%) and FOV (65–120°) sliders. Drag with the mouse or use Left/Right on the focused slider; the value diff --git a/xtask/src/duo.rs b/xtask/src/duo.rs index 76bd84e..7323fe1 100644 --- a/xtask/src/duo.rs +++ b/xtask/src/duo.rs @@ -176,7 +176,7 @@ pub fn run(root: &Path) -> Res<()> { })?; host.send("ui_start_match")?; for (player, key) in [(&mut host, "HOST_CMDS"), (&mut client, "CLIENT_CMDS")] { - let commands = std::env::var(key).unwrap_or_else(|_| "spawn assault".into()); + let commands = std::env::var(key).unwrap_or_else(|_| "spawn 0".into()); player.send(&format!("wait world; {commands}"))?; } println!("Connected. Commands: host