mirror of
https://github.com/vladtrc/iw4L
synced 2026-10-26 02:11:39 -04:00
Support personal loadouts and fix lobby and menu interactions
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()?,
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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(),
|
||||
|
||||
@@ -22,6 +22,7 @@ pub enum ClientAction {
|
||||
request_id: ActionRequestId,
|
||||
class_id: ClassId,
|
||||
revision: u32,
|
||||
loadout: crate::PersonalClass,
|
||||
},
|
||||
|
||||
GiveWeapon {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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,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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user