Support personal loadouts and fix lobby and menu interactions

This commit is contained in:
vtroc7
2026-09-30 04:38:21 +03:00
parent 1a0daffd18
commit 60f550a2b7
38 changed files with 606 additions and 349 deletions
+12 -12
View File
@@ -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 <demo> e.g. make bench demo0011"; echo " make bench ZONE=<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 <zone> 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 <demo> play iw4l-artifacts/demos/<demo>.iw4ldemo, then quit"
+1 -1
View File
@@ -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
```
@@ -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,
+46 -29
View File
@@ -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());
+23 -10
View File
@@ -184,30 +184,43 @@ pub(crate) fn apply_master_volume(
pub(crate) fn sync_player_name(
settings: Res<frame::GameSettings>,
mut installed: MessageReader<frame::MatchInstalled>,
generation: Res<frame::WorldGeneration>,
has_world: Res<frame::HasWorld>,
role: Res<frame::RuntimeRole>,
local: Option<Res<net::LocalPresentClient>>,
link: Option<Res<net::UdpClientLink>>,
mut inbox: Option<ResMut<net::ClientActionInbox>>,
mut seq: ResMut<net::ActionRequestIds>,
mut sent: Local<Option<(frame::WorldGeneration, sim::ClientId, [u8; 16])>>,
) {
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);
}
}
+3 -3
View File
@@ -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,
+12
View File
@@ -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::<usize>().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),
+7
View File
@@ -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) {
+1
View File
@@ -126,6 +126,7 @@ pub(crate) fn parse(script: &str) -> Vec<Command> {
"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 {
+1 -1
View File
@@ -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"
),
}
}
+17 -4
View File
@@ -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<CmdSeq>) {
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
@@ -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
}
+1 -1
View File
@@ -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;
+18
View File
@@ -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<ClientAction,
request_id: input.get_u32()?,
class_id: ClassId(input.get_u32()?),
revision: input.get_u32()?,
loadout: sim::PersonalClass {
weapons: [
input.get_u32()?,
input.get_u32()?,
input.get_u32()?,
input.get_u32()?,
],
perks: [input.get_u32()?, input.get_u32()?, input.get_u32()?],
deathstreak: input.get_u8()?,
},
}),
2 => Ok(ClientAction::JoinMatch {
request_id: input.get_u32()?,
+1 -1
View File
@@ -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}")
+2 -4
View File
@@ -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") {
+75 -50
View File
@@ -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<sim::PersonalClass, String> {
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)
}
+2 -19
View File
@@ -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<ClassRow> {
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(
+10 -24
View File
@@ -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(),
+1
View File
@@ -22,6 +22,7 @@ pub enum ClientAction {
request_id: ActionRequestId,
class_id: ClassId,
revision: u32,
loadout: crate::PersonalClass,
},
GiveWeapon {
+4 -4
View File
@@ -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,
+3 -2
View File
@@ -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
+35
View File
@@ -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<ClassDef> {
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 {
+6 -9
View File
@@ -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| {
+56 -17
View File
@@ -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::<Runtime>();
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<crate::ClassDef> {
world
.resource::<Runtime>()
.personal_classes
.get(&(client, class.0))
.cloned()
}
pub(crate) fn choose_class(world: &mut World, client: u32, class: &crate::ClassDef) {
world
.resource_mut::<Runtime>()
.personal_classes
.insert((client, class.id.0), class.clone());
let realm = world
.resource::<Runtime>()
.program
@@ -346,17 +361,30 @@ pub(crate) fn choose_class(world: &mut World, client: u32, class: &crate::ClassD
.resource_mut::<Runtime>()
.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<bool> {
@@ -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>();
runtime
.personal_classes
.retain(|(owner, _), _| *owner != client);
runtime.weapon_bridge.remove(&client);
}
let Some(slot) = world.resource::<Runtime>().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::<Runtime>()
.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::<Runtime>();
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),
+2
View File
@@ -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<u64> = runtime.hud_slots.keys().copied().collect();
let clients: Vec<u32> = 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>();
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 {
+1 -1
View File
@@ -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};
+1
View File
@@ -47,6 +47,7 @@ pub(crate) struct Runtime {
pub(crate) engine: host::entities::EngineState,
pub(crate) players: BTreeMap<u32, host::players::PlayerSlot>,
pub(crate) menu_answers: BTreeMap<u32, VecDeque<host::players::MenuAnswer>>,
pub(crate) personal_classes: BTreeMap<(u32, u32), crate::ClassDef>,
pub(crate) weapon_bridge: BTreeMap<u32, Vec<(u32, u32)>>,
pub(crate) disconnects: std::collections::BTreeSet<u32>,
pub(crate) joined: std::collections::BTreeSet<u32>,
-7
View File
@@ -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()
+28 -42
View File
@@ -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);
}
}
+20 -20
View File
@@ -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": {
+36 -7
View File
@@ -16,6 +16,7 @@ pub struct EquipTxnWatch {
pub fn apply_pending_class_equip(
mut pending: ResMut<PendingClassEquip>,
store: Res<SessionClassStore>,
weapons: Option<Res<assets::PreparedWeapons>>,
mut actions: ResMut<ClientActionInbox>,
mut watch: ResMut<EquipTxnWatch>,
mut phase: ResMut<ClassSelectPhase>,
@@ -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<SessionClassStore>,
mut phase: ResMut<ClassSelectPhase>,
mut overlay: ResMut<ClassSelectOverlayOpen>,
mut status: ResMut<ClassSelectStatus>,
@@ -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<frame::ClassSelectHandoff>,
mut catalog: ResMut<crate::ClassLoadoutCatalog>,
mut store: ResMut<SessionClassStore>,
weapons: Option<Res<assets::PreparedWeapons>>,
) {
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<SessionClassStore>,
mut torn: MessageReader<frame::MatchTornDown>,
mut watch: ResMut<EquipTxnWatch>,
mut pending: ResMut<PendingClassEquip>,
@@ -261,6 +289,7 @@ fn reset_equip_transaction(
return;
}
torn.clear();
store.equipped = None;
*watch = EquipTxnWatch::default();
pending.0 = None;
*phase = ClassSelectPhase::default();
+4 -2
View File
@@ -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
+70 -44
View File
@@ -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<asset_game::WeaponRegistry>) -> 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(),
+91 -24
View File
@@ -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<ClassSlotState>,
@@ -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<HostClassSlot> =
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<Vec<ClassSlotState>> {
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<Res<frame::LaunchIdentity>>,
mut file: ResMut<ClassStoreFile>,
mut store: ResMut<SessionClassStore>,
catalog: Res<crate::ClassLoadoutCatalog>,
) {
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<SessionClassStore>, mut file: ResMut<ClassStoreFile>) {
pub(crate) fn save_class_store(
store: Res<SessionClassStore>,
catalog: Res<crate::ClassLoadoutCatalog>,
mut file: ResMut<ClassStoreFile>,
) {
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<SessionClassStore>, mut file: ResMut<C
if file.written.as_ref() == Some(&contents) {
return;
}
let saved = file.written.as_deref().and_then(decode_slots);
for (index, slot) in store.slots.iter().enumerate() {
if let Err(reason) = catalog.validate_class(slot) {
let unchanged = saved
.as_ref()
.and_then(|slots| slots.get(index))
.is_some_and(|old| HostClassSlot::from(old) == HostClassSlot::from(slot));
if !unchanged {
file.retry_at = None;
diag::warn!(Ui, "classes: not saved: {reason}");
return;
}
}
}
match write_class_file(&path, &contents) {
Ok(()) => {
file.written = Some(contents);
+2 -2
View File
@@ -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
+4 -3
View File
@@ -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
+1 -1
View File
@@ -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 <script> | client <script> | both <script> | quit");