feat: GuiPresence (chat-service presence backend, gateway route, mod module); restore TODO history
This commit is contained in:
parent
8d9a2ad0d6
commit
e82efb74e7
44 changed files with 2202 additions and 19 deletions
|
|
@ -19,6 +19,7 @@ GATEWAY_PORT=8080
|
|||
ACCOUNTS_HTTP_URL=http://127.0.0.1:8081
|
||||
ACCOUNTS_GRPC_URL=http://127.0.0.1:50051
|
||||
CONFIGS_HTTP_URL=http://127.0.0.1:8082
|
||||
CHAT_HTTP_URL=http://127.0.0.1:8083
|
||||
SITE_ORIGIN=http://localhost:5173
|
||||
TRUST_PROXY=false
|
||||
# Directory the gateway serves under GET /downloads/* (lovisual.jar lives here;
|
||||
|
|
|
|||
17
backend/Cargo.lock
generated
17
backend/Cargo.lock
generated
|
|
@ -860,6 +860,23 @@ dependencies = [
|
|||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chat-service"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"axum-test",
|
||||
"common",
|
||||
"dotenvy",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
resolver = "2"
|
||||
members = [
|
||||
"accounts-service",
|
||||
"chat-service",
|
||||
"common",
|
||||
"configs-service",
|
||||
"gateway",
|
||||
|
|
@ -9,5 +10,4 @@ members = [
|
|||
|
||||
# Planned members, added when their own implementation plan starts
|
||||
# (see backend/STRUCTURE.md and TODO.md Фаза 10 / Подсистемы 1-3):
|
||||
# "chat-service" — RPC chat, friends, presence, telemetry widgets (Подсистема 2)
|
||||
# "addons-registry" — addon marketplace backend (Подсистема 3)
|
||||
|
|
|
|||
|
|
@ -17,9 +17,9 @@ backend/
|
|||
configs-service/ # РЕАЛИЗОВАН — configs-service/PLAN.md: 4 слота конфигов,
|
||||
# share-коды, витрина publish/browse/detail/copy
|
||||
# (Подсистема 1, часть 2)
|
||||
chat-service/ # ПЛАН НЕ НАПИСАН — RPC-чат, друзья, presence,
|
||||
# виджеты-телеметрия (Подсистема 2). Единственный
|
||||
# сервис с WebSocket, а не только REST.
|
||||
chat-service/ # РЕАЛИЗОВАН ЧАСТИЧНО — chat-service/PLAN.md: сейчас только GUI presence
|
||||
# (в памяти, POST /presence/gui); RPC-чат, друзья, виджеты-телеметрия
|
||||
# (Подсистема 2) позже, там будет WebSocket.
|
||||
addons-registry/ # ПЛАН НЕ НАПИСАН — витрина аддонов, версии,
|
||||
# публикация/модерация (Подсистема 3)
|
||||
```
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ COPY common ./common
|
|||
COPY accounts-service ./accounts-service
|
||||
COPY gateway ./gateway
|
||||
COPY configs-service ./configs-service
|
||||
COPY chat-service ./chat-service
|
||||
RUN cargo build --release -p accounts-service
|
||||
|
||||
FROM debian:trixie-slim
|
||||
|
|
|
|||
23
backend/chat-service/Cargo.toml
Normal file
23
backend/chat-service/Cargo.toml
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
[package]
|
||||
name = "chat-service"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[lib]
|
||||
name = "chat_service"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
common = { path = "../common" }
|
||||
axum = { version = "0.8", features = ["macros"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = "0.3"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
anyhow = "1"
|
||||
dotenvy = "0.15"
|
||||
|
||||
[dev-dependencies]
|
||||
axum-test = "21"
|
||||
20
backend/chat-service/Dockerfile
Normal file
20
backend/chat-service/Dockerfile
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# Build context must be backend/ (the workspace root).
|
||||
FROM rust:1-trixie AS builder
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
protobuf-compiler pkg-config libssl-dev ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /build
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY common ./common
|
||||
COPY accounts-service ./accounts-service
|
||||
COPY gateway ./gateway
|
||||
COPY configs-service ./configs-service
|
||||
COPY chat-service ./chat-service
|
||||
RUN cargo build --release -p chat-service
|
||||
|
||||
FROM debian:trixie-slim
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates libssl3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=builder /build/target/release/chat-service /usr/local/bin/chat-service
|
||||
ENTRYPOINT ["/usr/local/bin/chat-service"]
|
||||
47
backend/chat-service/PLAN.md
Normal file
47
backend/chat-service/PLAN.md
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
# chat-service
|
||||
|
||||
Подсистема 2 платформы (RPC-чат, друзья, presence, телеметрия виджетов). Сейчас реализован только **GUI presence**;
|
||||
чат и друзья позже (WebSocket). Сервис без БД: состояние в памяти, живёт секунды.
|
||||
|
||||
## GUI presence (идея из evo `GuiPresence`, наш вариант)
|
||||
|
||||
Зачем: LoVisual-игроки видят друг у друга над рукой мини-копию открытого меню клиента (какая категория, куда наведён курсор,
|
||||
поиск), и знают, кто вообще пользуется LoVisual (значок у ника). Без видео и без чужих данных: только положение курсора и флаги.
|
||||
|
||||
### Протокол
|
||||
`POST /presence/gui` (через gateway, нужен device-токен `lvd_...`, gateway кладёт `x-lovisual-account-id`):
|
||||
|
||||
```json
|
||||
{ "server": "play.example.com", "mc": "<uuid игрока>", "gui": "<base64>|null", "want": ["<uuid>", "..."], "publish": true }
|
||||
```
|
||||
Ответ: `{ "players": [ { "mc": "<uuid>", "gui": "<base64>|null", "age": 120 } ] }`, `age` в мс с последнего обновления.
|
||||
|
||||
- Один запрос и публикует состояние вызывающего, и возвращает состояние тех, кого он видит (`want`, не больше 40).
|
||||
- `publish = false` (по умолчанию true): только чтение, вызывающий сам не появляется у других.
|
||||
- `gui = null` значит «меню закрыто, но LoVisual запущен» (значок у ника). Запись живёт 6 с без обновления.
|
||||
- Игроки разных серверов не видят друг друга (ключ `server`, нормализуется в нижний регистр, ≤64 символов).
|
||||
- Частота клиента: раз в ~350 мс при открытом меню (пачка до 8 сэмплов курсора по 100 мс), раз в 2 с при закрытом.
|
||||
Лимит на gateway: 6 запросов в секунду на аккаунт.
|
||||
|
||||
### Формат `gui` (36 байт максимум, base64 ≤48 символов; сервис его не разбирает, только проверяет алфавит и длину)
|
||||
| байт | значение |
|
||||
|---|---|
|
||||
| 0 | версия = 1 |
|
||||
| 1 | биты 0-3 категория (0..15), бит 4 открыта страница настроек, бит 5 поиск, бит 7 меню закрыто |
|
||||
| 2 | индекс модуля под курсором в списке категории (255 = нет) |
|
||||
| 3 | число сэмплов n (0..8) |
|
||||
| 4+4i | сэмпл: x (0..255 по ширине меню), y (0..255 по высоте), бит 0 = зажата ЛКМ, прокрутка списка (0..255) |
|
||||
|
||||
### Безопасность
|
||||
- Идентичность только из заголовка gateway; прямой доступ закрыт общим секретом (как у остальных сервисов).
|
||||
- Привязка `mc uuid` к аккаунту: чужой аккаунт не может публиковать за занятый uuid (409), привязка снимается через 10 мин
|
||||
простоя; аккаунт владеет одним uuid, смена альта освобождает прежний. Это защита от подделки, не доказательство владения
|
||||
аккаунтом Minecraft: серверной верификации uuid нет.
|
||||
- Размер тела ≤8 КБ, payload только `[A-Za-z0-9+/=]`, `want` ≤40.
|
||||
|
||||
### Структура
|
||||
`src/presence/{payload.rs (валидация), store.rs (состояние + TTL + привязка), handlers.rs (sync)}`; тесты: модульные в `store.rs`,
|
||||
`payload.rs`, интеграционные `tests/presence.rs`. Порт `CHAT_PORT` (8083), `INTERNAL_KEY` общий. В gateway маршрут `/presence/*`.
|
||||
|
||||
### Не сделано
|
||||
Просмотр видео (`.watch` у evo), чат и друзья (WebSocket), метрики, горизонтальное масштабирование (состояние в памяти одного процесса).
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
# chat-service (planned)
|
||||
|
||||
RPC chat, friends, presence, telemetry widgets (Подсистема 2). WebSocket, not
|
||||
just REST — see `TODO.md` (Фаза 10, "Подсистема 2") and
|
||||
`backend/STRUCTURE.md`. No implementation plan yet.
|
||||
45
backend/chat-service/src/config.rs
Normal file
45
backend/chat-service/src/config.rs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
use anyhow::{Context, Result};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Config {
|
||||
pub port: u16,
|
||||
pub internal_key: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn from_env() -> Result<Config> {
|
||||
Ok(Config {
|
||||
port: std::env::var("CHAT_PORT")
|
||||
.unwrap_or_else(|_| "8083".into())
|
||||
.parse()
|
||||
.context("CHAT_PORT")?,
|
||||
internal_key: std::env::var("INTERNAL_KEY").context("INTERNAL_KEY not set")?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if self.internal_key.len() < 32 {
|
||||
anyhow::bail!("INTERNAL_KEY must be at least 32 bytes");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn short_key_is_rejected() {
|
||||
let c = Config {
|
||||
port: 0,
|
||||
internal_key: "short".into(),
|
||||
};
|
||||
assert!(c.validate().is_err());
|
||||
let c = Config {
|
||||
port: 0,
|
||||
internal_key: "k".repeat(32),
|
||||
};
|
||||
assert!(c.validate().is_ok());
|
||||
}
|
||||
}
|
||||
27
backend/chat-service/src/lib.rs
Normal file
27
backend/chat-service/src/lib.rs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
pub mod config;
|
||||
pub mod presence;
|
||||
|
||||
use axum::{Router, extract::DefaultBodyLimit, routing::{get, post}};
|
||||
use common::internal::{InternalKey, require_internal_key};
|
||||
use config::Config;
|
||||
use presence::store::Store;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Presence requests are tiny JSON (a ≤64-char payload and ≤40 uuids).
|
||||
const MAX_BODY_BYTES: usize = 8 * 1024;
|
||||
|
||||
pub fn build_app(cfg: &Config) -> (Router, Arc<Store>) {
|
||||
let store = Arc::new(Store::default());
|
||||
let api = Router::new()
|
||||
.route("/presence/gui", post(presence::handlers::sync))
|
||||
.with_state(Arc::clone(&store))
|
||||
.layer(DefaultBodyLimit::max(MAX_BODY_BYTES))
|
||||
.layer(axum::middleware::from_fn_with_state(
|
||||
InternalKey::new(cfg.internal_key.clone()),
|
||||
require_internal_key,
|
||||
));
|
||||
let app = Router::new()
|
||||
.route("/health", get(|| async { "ok" }))
|
||||
.merge(api);
|
||||
(app, store)
|
||||
}
|
||||
13
backend/chat-service/src/main.rs
Normal file
13
backend/chat-service/src/main.rs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
dotenvy::dotenv().ok();
|
||||
tracing_subscriber::fmt::init();
|
||||
let cfg = chat_service::config::Config::from_env()?;
|
||||
cfg.validate()?;
|
||||
let (app, state) = chat_service::build_app(&cfg);
|
||||
chat_service::presence::spawn_purger(state);
|
||||
let listener = tokio::net::TcpListener::bind(("0.0.0.0", cfg.port)).await?;
|
||||
tracing::info!("chat-service listening on {}", cfg.port);
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
77
backend/chat-service/src/presence/handlers.rs
Normal file
77
backend/chat-service/src/presence/handlers.rs
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
use super::{
|
||||
payload::{MAX_WANT, normalize_server, valid_payload},
|
||||
store::{PublishError, Store},
|
||||
};
|
||||
use axum::{
|
||||
Json,
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use common::internal::GatewayIdentity;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use std::{sync::Arc, time::Instant};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SyncRequest {
|
||||
pub server: String,
|
||||
pub mc: Uuid,
|
||||
/// Base64 GUI payload, or null when this client has no menu open (still
|
||||
/// marks the player as a LoVisual user for a few seconds).
|
||||
#[serde(default)]
|
||||
pub gui: Option<String>,
|
||||
/// Minecraft uuids of nearby players the caller wants state for.
|
||||
#[serde(default)]
|
||||
pub want: Vec<Uuid>,
|
||||
/// False for a read-only poll: the caller sees others but does not appear itself.
|
||||
#[serde(default = "yes")]
|
||||
pub publish: bool,
|
||||
}
|
||||
|
||||
fn yes() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct PlayerState {
|
||||
pub mc: Uuid,
|
||||
pub gui: Option<String>,
|
||||
pub age: u64,
|
||||
}
|
||||
|
||||
fn error(status: StatusCode, message: &str) -> Response {
|
||||
(status, Json(json!({ "error": message }))).into_response()
|
||||
}
|
||||
|
||||
/// One round trip both publishes the caller's state and returns the state of
|
||||
/// the players the caller can see (same request as the mod's poll loop).
|
||||
pub async fn sync(
|
||||
State(store): State<Arc<Store>>,
|
||||
id: GatewayIdentity,
|
||||
Json(req): Json<SyncRequest>,
|
||||
) -> Response {
|
||||
let Some(server) = normalize_server(&req.server) else {
|
||||
return error(StatusCode::BAD_REQUEST, "invalid server");
|
||||
};
|
||||
if req.want.len() > MAX_WANT {
|
||||
return error(StatusCode::BAD_REQUEST, "too many players requested");
|
||||
}
|
||||
if req.gui.as_deref().is_some_and(|g| !valid_payload(g)) {
|
||||
return error(StatusCode::BAD_REQUEST, "invalid gui payload");
|
||||
}
|
||||
let now = Instant::now();
|
||||
if req.publish
|
||||
&& let Err(PublishError::Claimed) = store.publish(id.account_id, &server, req.mc, req.gui, now)
|
||||
{
|
||||
return error(StatusCode::CONFLICT, "player uuid is bound to another account");
|
||||
}
|
||||
let players: Vec<PlayerState> = store
|
||||
.lookup(&server, &req.want, now)
|
||||
.into_iter()
|
||||
.filter(|s| s.mc != req.mc)
|
||||
.map(|s| PlayerState { mc: s.mc, gui: s.gui, age: s.age_ms })
|
||||
.collect();
|
||||
Json(json!({ "players": players })).into_response()
|
||||
}
|
||||
20
backend/chat-service/src/presence/mod.rs
Normal file
20
backend/chat-service/src/presence/mod.rs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
//! GUI presence: lets LoVisual users see which client menu nearby LoVisual
|
||||
//! users have open. State is in-memory and short-lived (a few seconds), never
|
||||
//! persisted; see `backend/chat-service/PLAN.md`.
|
||||
|
||||
pub mod handlers;
|
||||
pub mod payload;
|
||||
pub mod store;
|
||||
|
||||
use std::{sync::Arc, time::{Duration, Instant}};
|
||||
use store::Store;
|
||||
|
||||
pub fn spawn_purger(store: Arc<Store>) {
|
||||
tokio::spawn(async move {
|
||||
let mut tick = tokio::time::interval(Duration::from_secs(30));
|
||||
loop {
|
||||
tick.tick().await;
|
||||
store.purge(Instant::now());
|
||||
}
|
||||
});
|
||||
}
|
||||
46
backend/chat-service/src/presence/payload.rs
Normal file
46
backend/chat-service/src/presence/payload.rs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
//! Validation of the opaque GUI payload and server key. The payload is a
|
||||
//! base64 string produced by the mod (header + up to 8 cursor samples); the
|
||||
//! service never decodes it, it only bounds what it stores and relays.
|
||||
|
||||
/// Header (4 bytes) + 8 samples x 4 bytes = 36 bytes = 48 base64 characters.
|
||||
pub const MAX_PAYLOAD_CHARS: usize = 48;
|
||||
pub const MAX_SERVER_CHARS: usize = 64;
|
||||
pub const MAX_WANT: usize = 40;
|
||||
|
||||
pub fn valid_payload(s: &str) -> bool {
|
||||
!s.is_empty()
|
||||
&& s.len() <= MAX_PAYLOAD_CHARS
|
||||
&& s.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'='))
|
||||
}
|
||||
|
||||
/// Normalised server key: trimmed, lower-case, no control characters, bounded.
|
||||
pub fn normalize_server(s: &str) -> Option<String> {
|
||||
let s = s.trim().to_ascii_lowercase();
|
||||
if s.is_empty() || s.chars().count() > MAX_SERVER_CHARS || s.chars().any(|c| c.is_control()) {
|
||||
return None;
|
||||
}
|
||||
Some(s)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn payload_charset_and_length() {
|
||||
assert!(valid_payload("AQEAAAA="));
|
||||
assert!(!valid_payload(""));
|
||||
assert!(!valid_payload("has space"));
|
||||
assert!(!valid_payload("<script>"));
|
||||
assert!(!valid_payload(&"A".repeat(MAX_PAYLOAD_CHARS + 1)));
|
||||
assert!(valid_payload(&"A".repeat(MAX_PAYLOAD_CHARS)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_is_normalised() {
|
||||
assert_eq!(normalize_server(" Play.Example.COM ").as_deref(), Some("play.example.com"));
|
||||
assert_eq!(normalize_server(""), None);
|
||||
assert_eq!(normalize_server("a\nb"), None);
|
||||
assert_eq!(normalize_server(&"x".repeat(65)), None);
|
||||
}
|
||||
}
|
||||
188
backend/chat-service/src/presence/store.rs
Normal file
188
backend/chat-service/src/presence/store.rs
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
use super::payload::MAX_WANT;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::Mutex,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// A published payload stops being relayed after this long without a refresh.
|
||||
pub const ENTRY_TTL: Duration = Duration::from_secs(6);
|
||||
/// A Minecraft uuid stays claimed by an account this long after its last publish.
|
||||
pub const CLAIM_TTL: Duration = Duration::from_secs(600);
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum PublishError {
|
||||
/// The uuid is currently claimed by a different account.
|
||||
Claimed,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct Seen {
|
||||
pub mc: Uuid,
|
||||
pub gui: Option<String>,
|
||||
pub age_ms: u64,
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
gui: Option<String>,
|
||||
updated: Instant,
|
||||
}
|
||||
|
||||
struct Claim {
|
||||
account: Uuid,
|
||||
seen: Instant,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Inner {
|
||||
entries: HashMap<(String, Uuid), Entry>,
|
||||
claims: HashMap<Uuid, Claim>,
|
||||
by_account: HashMap<Uuid, Uuid>,
|
||||
}
|
||||
|
||||
/// In-memory presence state. A uuid is bound to one account at a time so a
|
||||
/// client cannot impersonate somebody else's menu; an account owns one uuid at
|
||||
/// a time (switching alts releases the previous claim).
|
||||
#[derive(Default)]
|
||||
pub struct Store {
|
||||
inner: Mutex<Inner>,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub fn publish(
|
||||
&self,
|
||||
account: Uuid,
|
||||
server: &str,
|
||||
mc: Uuid,
|
||||
gui: Option<String>,
|
||||
now: Instant,
|
||||
) -> Result<(), PublishError> {
|
||||
let mut inner = self.inner.lock().expect("presence lock");
|
||||
if let Some(claim) = inner.claims.get(&mc)
|
||||
&& claim.account != account
|
||||
&& now.duration_since(claim.seen) < CLAIM_TTL
|
||||
{
|
||||
return Err(PublishError::Claimed);
|
||||
}
|
||||
if let Some(previous) = inner.by_account.insert(account, mc)
|
||||
&& previous != mc
|
||||
{
|
||||
inner.claims.remove(&previous);
|
||||
inner.entries.retain(|(_, id), _| *id != previous);
|
||||
}
|
||||
inner.claims.insert(mc, Claim { account, seen: now });
|
||||
inner
|
||||
.entries
|
||||
.insert((server.to_owned(), mc), Entry { gui, updated: now });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fresh entries for the requested uuids on this server (bounded).
|
||||
pub fn lookup(&self, server: &str, want: &[Uuid], now: Instant) -> Vec<Seen> {
|
||||
let inner = self.inner.lock().expect("presence lock");
|
||||
want.iter()
|
||||
.take(MAX_WANT)
|
||||
.filter_map(|mc| {
|
||||
let entry = inner.entries.get(&(server.to_owned(), *mc))?;
|
||||
let age = now.duration_since(entry.updated);
|
||||
(age < ENTRY_TTL).then(|| Seen {
|
||||
mc: *mc,
|
||||
gui: entry.gui.clone(),
|
||||
age_ms: age.as_millis() as u64,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn purge(&self, now: Instant) {
|
||||
let mut inner = self.inner.lock().expect("presence lock");
|
||||
inner.entries.retain(|_, e| now.duration_since(e.updated) < ENTRY_TTL);
|
||||
let expired: Vec<Uuid> = inner
|
||||
.claims
|
||||
.iter()
|
||||
.filter(|(_, c)| now.duration_since(c.seen) >= CLAIM_TTL)
|
||||
.map(|(mc, _)| *mc)
|
||||
.collect();
|
||||
for mc in expired {
|
||||
if let Some(claim) = inner.claims.remove(&mc) {
|
||||
inner.by_account.remove(&claim.account);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ids() -> (Uuid, Uuid, Uuid, Uuid) {
|
||||
(Uuid::from_u128(1), Uuid::from_u128(2), Uuid::from_u128(10), Uuid::from_u128(20))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn published_entry_is_visible_then_expires() {
|
||||
let (acc, _, mc, _) = ids();
|
||||
let store = Store::default();
|
||||
let t0 = Instant::now();
|
||||
store.publish(acc, "srv", mc, Some("AAAA".into()), t0).unwrap();
|
||||
let seen = store.lookup("srv", &[mc], t0 + Duration::from_millis(500));
|
||||
assert_eq!(seen.len(), 1);
|
||||
assert_eq!(seen[0].gui.as_deref(), Some("AAAA"));
|
||||
assert_eq!(seen[0].age_ms, 500);
|
||||
assert!(store.lookup("srv", &[mc], t0 + ENTRY_TTL).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_are_scoped_to_the_server() {
|
||||
let (acc, _, mc, _) = ids();
|
||||
let store = Store::default();
|
||||
let t0 = Instant::now();
|
||||
store.publish(acc, "a", mc, None, t0).unwrap();
|
||||
assert!(store.lookup("b", &[mc], t0).is_empty());
|
||||
assert_eq!(store.lookup("a", &[mc], t0).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_account_cannot_take_a_claimed_uuid() {
|
||||
let (acc, other, mc, _) = ids();
|
||||
let store = Store::default();
|
||||
let t0 = Instant::now();
|
||||
store.publish(acc, "s", mc, None, t0).unwrap();
|
||||
assert_eq!(store.publish(other, "s", mc, None, t0 + Duration::from_secs(1)), Err(PublishError::Claimed));
|
||||
// after the claim goes idle it can be taken over
|
||||
assert!(store.publish(other, "s", mc, None, t0 + CLAIM_TTL).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_uuid_releases_the_previous_claim() {
|
||||
let (acc, other, mc1, mc2) = ids();
|
||||
let store = Store::default();
|
||||
let t0 = Instant::now();
|
||||
store.publish(acc, "s", mc1, None, t0).unwrap();
|
||||
store.publish(acc, "s", mc2, None, t0).unwrap();
|
||||
assert!(store.lookup("s", &[mc1], t0).is_empty());
|
||||
assert!(store.publish(other, "s", mc1, None, t0).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lookup_is_bounded() {
|
||||
let (acc, _, mc, _) = ids();
|
||||
let store = Store::default();
|
||||
let t0 = Instant::now();
|
||||
store.publish(acc, "s", mc, None, t0).unwrap();
|
||||
let mut want: Vec<Uuid> = (100..100 + MAX_WANT as u128).map(Uuid::from_u128).collect();
|
||||
want.push(mc);
|
||||
assert!(store.lookup("s", &want, t0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purge_drops_stale_state() {
|
||||
let (acc, _, mc, _) = ids();
|
||||
let store = Store::default();
|
||||
let t0 = Instant::now();
|
||||
store.publish(acc, "s", mc, None, t0).unwrap();
|
||||
store.purge(t0 + CLAIM_TTL);
|
||||
assert!(store.publish(Uuid::from_u128(2), "s", mc, None, t0 + CLAIM_TTL).is_ok());
|
||||
}
|
||||
}
|
||||
71
backend/chat-service/tests/presence.rs
Normal file
71
backend/chat-service/tests/presence.rs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
use axum_test::TestServer;
|
||||
use chat_service::config::Config;
|
||||
use common::internal::{ACCOUNT_ID_HEADER, INTERNAL_KEY_HEADER};
|
||||
use serde_json::{Value, json};
|
||||
use uuid::Uuid;
|
||||
|
||||
const KEY: &str = "kkkkkkkkkkkkkkkkkkkkkkkkkkkkkkkk";
|
||||
|
||||
fn server() -> TestServer {
|
||||
let cfg = Config { port: 0, internal_key: KEY.into() };
|
||||
let (app, _) = chat_service::build_app(&cfg);
|
||||
TestServer::new(app)
|
||||
}
|
||||
|
||||
async fn sync(s: &TestServer, account: u128, body: Value) -> axum_test::TestResponse {
|
||||
s.post("/presence/gui")
|
||||
.add_header(INTERNAL_KEY_HEADER, KEY)
|
||||
.add_header(ACCOUNT_ID_HEADER, Uuid::from_u128(account).to_string())
|
||||
.json(&body)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn requires_internal_key_and_identity() {
|
||||
let s = server();
|
||||
let r = s.post("/presence/gui").json(&json!({})).await;
|
||||
assert_eq!(r.status_code(), 403);
|
||||
let r = s
|
||||
.post("/presence/gui")
|
||||
.add_header(INTERNAL_KEY_HEADER, KEY)
|
||||
.json(&json!({"server": "a", "mc": Uuid::nil()}))
|
||||
.await;
|
||||
assert_eq!(r.status_code(), 401);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn two_players_see_each_other() {
|
||||
let s = server();
|
||||
let (a, b) = (Uuid::from_u128(10), Uuid::from_u128(20));
|
||||
let r = sync(&s, 1, json!({"server": "Play.X", "mc": a, "gui": "AQEAAAA=", "want": [b]})).await;
|
||||
assert_eq!(r.status_code(), 200);
|
||||
assert_eq!(r.json::<Value>()["players"].as_array().unwrap().len(), 0);
|
||||
let r = sync(&s, 2, json!({"server": "play.x", "mc": b, "gui": null, "want": [a]})).await;
|
||||
let body = r.json::<Value>();
|
||||
let players = body["players"].as_array().unwrap();
|
||||
assert_eq!(players.len(), 1);
|
||||
assert_eq!(players[0]["mc"], a.to_string());
|
||||
assert_eq!(players[0]["gui"], "AQEAAAA=");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_bad_input_and_uuid_theft() {
|
||||
let s = server();
|
||||
let mc = Uuid::from_u128(10);
|
||||
let r = sync(&s, 1, json!({"server": "x", "mc": mc, "gui": "<bad>"})).await;
|
||||
assert_eq!(r.status_code(), 400);
|
||||
let r = sync(&s, 1, json!({"server": "", "mc": mc})).await;
|
||||
assert_eq!(r.status_code(), 400);
|
||||
assert_eq!(sync(&s, 1, json!({"server": "x", "mc": mc})).await.status_code(), 200);
|
||||
let r = sync(&s, 2, json!({"server": "x", "mc": mc})).await;
|
||||
assert_eq!(r.status_code(), 409);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_only_poll_does_not_publish() {
|
||||
let s = server();
|
||||
let (a, b) = (Uuid::from_u128(10), Uuid::from_u128(20));
|
||||
sync(&s, 1, json!({"server": "x", "mc": a, "publish": false, "want": [b]})).await;
|
||||
let r = sync(&s, 2, json!({"server": "x", "mc": b, "want": [a]})).await;
|
||||
assert_eq!(r.json::<Value>()["players"].as_array().unwrap().len(), 0);
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ COPY common ./common
|
|||
COPY accounts-service ./accounts-service
|
||||
COPY gateway ./gateway
|
||||
COPY configs-service ./configs-service
|
||||
COPY chat-service ./chat-service
|
||||
RUN cargo build --release -p configs-service
|
||||
|
||||
FROM debian:trixie-slim
|
||||
|
|
|
|||
|
|
@ -97,6 +97,16 @@ services:
|
|||
CONFIGS_PORT: 8082
|
||||
networks: [lovisual-internal]
|
||||
|
||||
chat-service:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: chat-service/Dockerfile
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
CHAT_PORT: 8083
|
||||
networks: [lovisual-internal]
|
||||
|
||||
gateway:
|
||||
build:
|
||||
context: .
|
||||
|
|
@ -107,12 +117,15 @@ services:
|
|||
condition: service_started
|
||||
configs-service:
|
||||
condition: service_started
|
||||
chat-service:
|
||||
condition: service_started
|
||||
env_file: .env
|
||||
environment:
|
||||
GATEWAY_PORT: 8080
|
||||
ACCOUNTS_HTTP_URL: http://accounts-service:8081
|
||||
ACCOUNTS_GRPC_URL: http://accounts-service:50051
|
||||
CONFIGS_HTTP_URL: http://configs-service:8082
|
||||
CHAT_HTTP_URL: http://chat-service:8083
|
||||
# Read-only static files served under GET /downloads/* (lovisual.jar).
|
||||
DOWNLOADS_DIR: /srv/downloads
|
||||
volumes:
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ COPY common ./common
|
|||
COPY accounts-service ./accounts-service
|
||||
COPY gateway ./gateway
|
||||
COPY configs-service ./configs-service
|
||||
COPY chat-service ./chat-service
|
||||
RUN cargo build --release -p gateway
|
||||
|
||||
FROM debian:trixie-slim
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ pub struct Config {
|
|||
pub accounts_http_url: String,
|
||||
pub accounts_grpc_url: String,
|
||||
pub configs_http_url: String,
|
||||
pub chat_http_url: String,
|
||||
pub site_origin: String,
|
||||
/// Directory served read-only under `GET /downloads/*` — today just
|
||||
/// `lovisual.jar`, the mod's direct download (see TODO.md «Скачивание»).
|
||||
|
|
@ -33,6 +34,7 @@ impl Config {
|
|||
accounts_http_url: var("ACCOUNTS_HTTP_URL")?,
|
||||
accounts_grpc_url: var("ACCOUNTS_GRPC_URL")?,
|
||||
configs_http_url: var("CONFIGS_HTTP_URL")?,
|
||||
chat_http_url: var("CHAT_HTTP_URL")?,
|
||||
site_origin: var("SITE_ORIGIN")?,
|
||||
downloads_dir: std::env::var("DOWNLOADS_DIR").unwrap_or_else(|_| "downloads".into()),
|
||||
trust_proxy: std::env::var("TRUST_PROXY").is_ok_and(|v| v == "true"),
|
||||
|
|
@ -71,6 +73,7 @@ mod tests {
|
|||
accounts_http_url: String::new(),
|
||||
accounts_grpc_url: String::new(),
|
||||
configs_http_url: String::new(),
|
||||
chat_http_url: String::new(),
|
||||
site_origin: "http://localhost:5173".into(),
|
||||
downloads_dir: "downloads".into(),
|
||||
trust_proxy: false,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ pub struct Upstreams {
|
|||
pub client: reqwest::Client,
|
||||
pub accounts: String,
|
||||
pub configs: String,
|
||||
pub chat: String,
|
||||
pub internal_key: HeaderValue,
|
||||
}
|
||||
|
||||
|
|
@ -44,6 +45,7 @@ impl Upstreams {
|
|||
.build()?,
|
||||
accounts: cfg.accounts_http_url.trim_end_matches('/').to_owned(),
|
||||
configs: cfg.configs_http_url.trim_end_matches('/').to_owned(),
|
||||
chat: cfg.chat_http_url.trim_end_matches('/').to_owned(),
|
||||
internal_key: HeaderValue::from_str(&cfg.internal_key)?,
|
||||
})
|
||||
}
|
||||
|
|
@ -67,6 +69,7 @@ pub async fn proxy(State(up): State<Arc<Upstreams>>, req: Request) -> Response {
|
|||
let base = match target {
|
||||
Upstream::Accounts => &up.accounts,
|
||||
Upstream::Configs => &up.configs,
|
||||
Upstream::Chat => &up.chat,
|
||||
};
|
||||
let path_and_query = req.uri().path_and_query().map_or("/", |p| p.as_str());
|
||||
let url = format!("{base}{path_and_query}");
|
||||
|
|
|
|||
|
|
@ -2,12 +2,14 @@
|
|||
pub enum Upstream {
|
||||
Accounts,
|
||||
Configs,
|
||||
Chat,
|
||||
}
|
||||
|
||||
pub fn upstream_for(path: &str) -> Option<Upstream> {
|
||||
match path.trim_start_matches('/').split('/').next()? {
|
||||
"auth" | "device" | "avatars" | "media" | "me" | "users" => Some(Upstream::Accounts),
|
||||
"configs" | "showcase" => Some(Upstream::Configs),
|
||||
"presence" => Some(Upstream::Chat),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
|
@ -26,6 +28,7 @@ mod tests {
|
|||
);
|
||||
assert_eq!(upstream_for("/configs/shared/ABC"), Some(Upstream::Configs));
|
||||
assert_eq!(upstream_for("/showcase"), Some(Upstream::Configs));
|
||||
assert_eq!(upstream_for("/presence/gui"), Some(Upstream::Chat));
|
||||
assert_eq!(upstream_for("/authx"), None);
|
||||
assert_eq!(upstream_for("/"), None);
|
||||
assert_eq!(upstream_for("/health"), None);
|
||||
|
|
|
|||
|
|
@ -60,6 +60,8 @@ pub fn rules() -> Vec<Rule> {
|
|||
),
|
||||
rule(Method::POST, "/avatars", Quota::per_hour(n(5)), Account),
|
||||
rule(Method::GET, "/showcase*", Quota::per_minute(n(60)), Ip),
|
||||
// The mod syncs menu presence about 3 times a second while a menu is open.
|
||||
rule(Method::POST, "/presence/*", Quota::per_second(n(6)), Account),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ pub fn config(accounts: &str, configs: &str) -> Config {
|
|||
accounts_http_url: accounts.into(),
|
||||
accounts_grpc_url: String::new(),
|
||||
configs_http_url: configs.into(),
|
||||
chat_http_url: String::new(),
|
||||
site_origin: "http://localhost:5173".into(),
|
||||
downloads_dir: std::env::temp_dir()
|
||||
.join("lovisual-downloads-test")
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
# poking and scripts/e2e.sh. Restarts them if they are already running.
|
||||
#
|
||||
# Prerequisites (one-time):
|
||||
# cargo build -p accounts-service -p configs-service -p gateway
|
||||
# cargo build -p accounts-service -p configs-service -p chat-service -p gateway
|
||||
# a Postgres with accounts_db + configs_db, and MinIO with the
|
||||
# lovisual-avatars-test bucket (see backend/PLAN.md, Task 2).
|
||||
# Dev-only secrets below are fine locally; production reads backend/.env.
|
||||
|
|
@ -25,21 +25,22 @@ export COOKIE_SECURE="false"
|
|||
# browser-facing avatar prefix served through the gateway (mirrors prod /api/media)
|
||||
export AVATAR_PUBLIC_BASE_URL="http://127.0.0.1:8080/media"
|
||||
|
||||
pkill -f 'target/debug/(accounts-service|configs-service|gateway)' 2>/dev/null || true
|
||||
pkill -f 'target/debug/(accounts-service|configs-service|chat-service|gateway)' 2>/dev/null || true
|
||||
sleep 1
|
||||
|
||||
PORT=8081 GRPC_PORT=50051 setsid nohup "$B/target/debug/accounts-service" >"$L/accounts.log" 2>&1 &
|
||||
CONFIGS_PORT=8082 setsid nohup "$B/target/debug/configs-service" >"$L/configs.log" 2>&1 &
|
||||
CHAT_PORT=8083 setsid nohup "$B/target/debug/chat-service" >"$L/chat.log" 2>&1 &
|
||||
GATEWAY_PORT=8080 ACCOUNTS_HTTP_URL="http://127.0.0.1:8081" \
|
||||
CONFIGS_HTTP_URL="http://127.0.0.1:8082" SITE_ORIGIN="http://localhost:5173" \
|
||||
CONFIGS_HTTP_URL="http://127.0.0.1:8082" CHAT_HTTP_URL="http://127.0.0.1:8083" SITE_ORIGIN="http://localhost:5173" \
|
||||
setsid nohup "$B/target/debug/gateway" >"$L/gateway.log" 2>&1 &
|
||||
|
||||
up() { [ "$(curl -sf "$1" 2>/dev/null || true)" = "ok" ]; }
|
||||
for i in $(seq 1 60); do
|
||||
up http://127.0.0.1:8081/health && up http://127.0.0.1:8082/health && up http://127.0.0.1:8080/health && break
|
||||
up http://127.0.0.1:8081/health && up http://127.0.0.1:8082/health && up http://127.0.0.1:8083/health && up http://127.0.0.1:8080/health && break
|
||||
sleep 0.5
|
||||
done
|
||||
for u in 8081 8082 8080; do
|
||||
for u in 8081 8082 8083 8080; do
|
||||
up "http://127.0.0.1:$u/health" || { echo "port $u did not come up"; tail -5 "$L"/*.log; exit 1; }
|
||||
done
|
||||
echo "accounts+configs+gateway up"
|
||||
echo "accounts+configs+chat+gateway up"
|
||||
|
|
|
|||
21
mod/TODO.md
21
mod/TODO.md
|
|
@ -1,6 +1,7 @@
|
|||
# LoVisual Mod (26.2): план и статус
|
||||
|
||||
Мод вырос из форка Combatant Client. Подробная история (фазы 1-12) лежит в git: `git log -p -- mod/TODO.md`.
|
||||
Мод вырос из форка Combatant Client. Полный старый текст (фазы 1-12, все решения, коммиты, списки файлов) сохранён
|
||||
в `docs/TODO_HISTORY.md` (770 строк, не правим); здесь только актуальное.
|
||||
|
||||
## Правила (обязательные)
|
||||
- KISS / DRY / SOLID, без оверинжиниринга; публичный API менять можно, мёртвый код удалять.
|
||||
|
|
@ -36,6 +37,13 @@
|
|||
`DeathBody`, `DeathMesh`, `DeathEffectHelper`; тело из 6 частей с UV скина игрока, у мобов серые кубы). Настройки в
|
||||
`DeathEffectSettings`. Не сделано: скрытие ванильного трупа на время эффекта, шейдер `death_blast`, режим Lightning из evo
|
||||
(у нас уже есть свой Lightning).
|
||||
- [x] GuiPresence (идея evo, наш бэкенд): `backend/chat-service` (новый крейт, in-memory, `POST /presence/gui`, привязка mc uuid к аккаунту,
|
||||
TTL 6 с, план и протокол в `backend/chat-service/PLAN.md`), маршрут `/presence/*` в gateway (+ `CHAT_HTTP_URL`, лимит 6/с на аккаунт),
|
||||
Dockerfile/compose/dev-stack. Мод: `platform/presence/{PresenceCodec,PresenceTimeline,PresenceClient}` (кодек 36 байт, плейбэк с
|
||||
задержкой 300 мс, адаптивный опрос 350 мс/2 с), модуль `visuals/presence/GuiPresence` (делиться своим меню, показывать чужие,
|
||||
дистанция, размер), снимок меню `ModulesMenuScreen.presenceSnapshot`. Мини-копия ClickGUI в мире перед игроком из цветных квадов
|
||||
(без текста). Нужны привязанный аккаунт (`%link`) и мультиплеер. Не сделано: значок «LoVisual» у ника/в табе, названия модулей в панели,
|
||||
просмотр видео (`.watch` у evo), деплой chat-service на прод (compose готов, нужен `docker compose up -d --build`).
|
||||
- [ ] Wings: режим «Модель» (Bedrock geo.json + animation.json, 14 видов; отчёт раздел 2.4). Шаги: `BedrockGeoParser` (общий с ножами),
|
||||
`BedrockAnimation` + процедурные angel/demon/dragon, `WingsRenderer.renderModel`, настройки `wingSource`/`wingModel`, тесты.
|
||||
Ресурсы (локально): `ref/evo/mod/jar/assets/evolution/cosmetic/wings/`, `.../textures/cosmetic/wings/`.
|
||||
|
|
@ -53,6 +61,7 @@
|
|||
- Режимы TargetESP Созвездие и Мечи; формы JumpCircles; кровь (лужи, яркость, depth); новые режимы KillEffect (ориентация
|
||||
граней частей, UV скина, яркость искр); ProjectileTrails (вид ленты, глубина); Hero/orbs-меню.
|
||||
- HoldMyItems Scripted: знаки осей `M:rotate`, видимость Lua-частиц, анимация квадов на реальной модели.
|
||||
- GuiPresence: нужен запущенный стек (`backend/scripts/dev-stack.sh`) и два клиента с привязанными аккаунтами на одном сервере.
|
||||
- Войсчат с настоящим SVC 2.6.24 (события mute/speaking, значок шапки HUD).
|
||||
- Размер плашки описания в ClickGUI и авто-масштаб тултипа при GUI Scale 2/3/4.
|
||||
|
||||
|
|
@ -61,7 +70,15 @@
|
|||
какой второй вид делать.
|
||||
- ViewModel: свободный `Custom` swing и режимы sacura (Возвращение, Круговая); HitEffect: distortion/glow post-process;
|
||||
JumpCircles: easing на появление; KillEffect: toggle «сквозь стены»; Trails: сравнение с sacura вживую.
|
||||
- Фаза 8.5.3: около 150 файлов 250-400 строк и папки >4 файлов, чинить по мере касания.
|
||||
- Фаза 8.5.3 (история: раздел 8.5): около 150 файлов 250-400 строк и папки >4 файлов. Правило: касаешься файла по другой
|
||||
задаче, первым коммитом разбей на ≤250 (фасад + package-private хелперы, API вызывающих не трогать), вторым целевое изменение.
|
||||
Очередь самых больших: LoVisualRenderPipelines, DraggableHudElementRegistry, Predictions, DynamicIsland, Module,
|
||||
HudNotifier, AltManager, Triangulator, TargetESP, UiPrimitiveRenderer. Фасады (Renderer2D и т.п.) остаются исключением.
|
||||
При переносах пакетов не ломать id модулей и ключи настроек (совместимость `.lvcfg`), после move проверять grep'ом.
|
||||
- Разнос `addon`/`events`/`mixininterface` по смыслу отложен (54/41/88 файлов-импортёров, чистый import-churn): только скриптовым
|
||||
отдельным проходом.
|
||||
- Фаза 9 (FPS) закрыта: мёртвые грузы починены в коде, ручной модуль Optimize есть; авто-деградацию по FPS решено не делать.
|
||||
- Фаза 7 (Delta QoL): Wasted и Arrows отменены, TapeMouse оставлен по решению владельца; список в истории.
|
||||
- `.gitignore` для `server-plugin/.classpath`, `.project`, `.settings/`.
|
||||
- Приёмка серверного плагина (Velocity + `%policy`), бэкенд chat-service и addons-registry (корневой TODO.md).
|
||||
|
||||
|
|
|
|||
770
mod/docs/TODO_HISTORY.md
Normal file
770
mod/docs/TODO_HISTORY.md
Normal file
|
|
@ -0,0 +1,770 @@
|
|||
# LoVisual Mod (combatant-client-26.2) — план переписи
|
||||
|
||||
Мод — форк Combatant Client, переписываем под LoVisual.
|
||||
|
||||
## Правила (ОБЯЗАТЕЛЬНЫЕ, нарушение = откат)
|
||||
- **KISS / DRY / SOLID** — простая структура, без оверинжиниринга
|
||||
- **Макс. 250 строк на файл** (было >1500) — превышение = разбить на модули
|
||||
- **Макс. 4 файла на папку** — больше = новая подпапка по смыслу
|
||||
- **Публичный API можно менять свободно** (решение владельца 2026-09-05):
|
||||
KISS важнее обратной совместимости; при рефакторинге разрешено менять
|
||||
сигнатуры, удалять делегирование и мёртвый код, обновляя вызывающих
|
||||
- **Коммиты — на английском** (решение 2026-09-23, действует с этого момента
|
||||
вперёд; более старые коммиты на русском не переписываем)
|
||||
- **Никакого статика** — `Minecraft.getInstance()` только лениво, никогда в поле
|
||||
- **Никаких this-escape** — конструктор не публикует `this`
|
||||
- **Никаких мёртвых миксинов** — миксин только если реально нужен; `cancellable=true` только при реальном cancel
|
||||
- **Без пустышек** — файл без реализации не создаётся
|
||||
- **Каждый этап компилируется** — `./gradlew build` перед переходом к следующему
|
||||
- **Тесты обязательны** для новых модулей (JUnit)
|
||||
|
||||
---
|
||||
|
||||
## СТАТУС ПРОБЛЕМ (что вижу сейчас)
|
||||
|
||||
### 1. Ребрендинг не закончен (пункт 2)
|
||||
- 288 файлов / **1464 упоминания** `combatant` в main-коде
|
||||
- Классы: `CombatantRenderSystem`, `CombatantRenderEngineBootstrap`, `CombatantRenderPipelines`, `CombatantVulkanBackend`, `CombatantFrameGraph`, `CombatantTerrainVertex` и др.
|
||||
- Заголовки файлов: «This file is part of the Combatant Client distribution» / копирайт изначального автора
|
||||
|
||||
### 2. Файлы-гиганты (пункт 3) — 12 файлов > 1500 строк
|
||||
| Файл | Строк |
|
||||
|------|-------|
|
||||
| render/engine/renderer/Renderer2D.java | 3379 |
|
||||
| features/gui/chat/BetterChatRenderer.java | 2870 |
|
||||
| features/gui/clickgui/settings/UnifiedSettingRenderer.java | 2798 |
|
||||
| features/module/modules/player/Scaffold.java | 2416 |
|
||||
| features/module/modules/combat/AttributeSwap.java | 1922 |
|
||||
| features/gui/clickgui/ClickGuiRenderer.java | 1900 |
|
||||
| features/module/modules/visuals/NameTags.java | 1791 |
|
||||
| features/module/modules/combat/ElytraTarget.java | 1711 |
|
||||
| features/gui/hud/draggable/impl/TargetHud.java | 1687 |
|
||||
| features/module/modules/visuals/ESP.java | 1520 |
|
||||
| features/module/modules/combat/AutoCrystal.java | 1516 |
|
||||
| features/gui/hud/nondraggable/impl/CustomBar.java | 1496 |
|
||||
|
||||
### 3. Runtime / JarReplacement (пункт 4)
|
||||
- `runtime/` (ClientRuntime, RuntimeGate, ClientRuntimeState, SOFT_PANIC/JAR_REPLACEMENT_PANIC, shutdown/resume-участники)
|
||||
- `runtime/loader/` (JarReplacementCoordinator, LoaderBridge, PanicOnlyJarReplacementCoordinator)
|
||||
- **Моё предложение: выкинуть.** Живая замена jar в памяти — источник classloader-утечек, хрупкая, для клиента не нужна (обновление делает лаунчер). Заменить на простой Lifecycle.
|
||||
|
||||
### 4. Security (пункт 7)
|
||||
- `features/security/BackdoorProtection` — на деле защита от SSRF/translate-инъекций (порт ExploitPreventer, MIT). Полезное, но `enum`-синглтон со статикой.
|
||||
- `features/relations/StaffTracker` + `StaffHeuristicsMatcher` — детект «стафа» по эвристикам TAB. Сомнительно, решить оставить/выкинуть.
|
||||
|
||||
### 5. Тесты (пункт 6)
|
||||
- 5 тестов на ~213k строк. Добавлять по мере переписи модулей.
|
||||
|
||||
---
|
||||
|
||||
## Фаза 1: Ребрендинг (пункт 2)
|
||||
- [x] **Восстановлен `config/`-пакет** (55+3 файла) из `ref/zip/combatant-client-26.2 (1).zip` — он отсутствовал в форке, без него проект не собирался. Переименован `combatant.client.config` → `dev.loki.lovisual.config`. `compileJava` ✓
|
||||
- [x] Заменить `combatant` → `lovisual` в main-коде (1464 вхождения, 288 файлов)
|
||||
- [x] Переименовать классы `Combatant*` → `LoVisual*` (23 шт., включая `IrisCombatantFrameHooks` → `IrisLoVisualFrameHooks`)
|
||||
- [x] Заголовки файлов → LoVisual, пересобрать копирайты (GPLv3 форка)
|
||||
- [x] Проверить `fabric.mod.json` / `lovisual.mixins.json` на остатки
|
||||
- [x] Проверить строки/сообщения/логи на «combatant»
|
||||
- [x] JNI-имена в native: `Java_combatant_client_` → `Java_dev_loki_lovisual_` (34 шт.) + FindClass-пути
|
||||
|
||||
### Проверка: `rg -i combatant src` → 0 ✓, `./gradlew build` ✓ (1406 файлов изменено)
|
||||
|
||||
## Фаза 2: Выкинуть Runtime/JarReplacement (пункт 4)
|
||||
> **Вместе с рантаймом удалить и модуль Panic** (`features/module/modules/misc/Panic.java`),
|
||||
> интерфейс `RuntimeControlModule`, команду `RuntimeCommand` и все ветки на них
|
||||
> в `ModuleManager`/`Module`. Panic — единственная реализация `RuntimeControlModule`,
|
||||
> без него они становятся мёртвым кодом.
|
||||
- [x] Удалить `runtime/` и `runtime/loader/` (14 файлов)
|
||||
- [x] Удалить `Panic.java` + `RuntimeControlModule.java` + `RuntimeCommand.java`
|
||||
- [x] Убрать ветки `instanceof RuntimeControlModule` и `tickRuntimeControllers()` из ModuleManager/Module
|
||||
- [x] Убрать `RuntimeGate` из точек входа (LoVisual.java, модули, рендер) — заменён на `Lifecycle`
|
||||
- [x] Заменить на простой Lifecycle-класс (`dev.loki.lovisual.Lifecycle`: INIT → ACTIVE → SHUTDOWN, гейты `canRunRender/Hud/ShaderBridge` + `RenderResourceReadiness`)
|
||||
- [x] Panic-фичи удалены; `ModuleManager.disableAll()` оставлен как простая утилита
|
||||
- [x] Мёртвые cleanup-методы удалены (`shutdownForRuntime`, `clearRuntimeReferences`, `VanillaChatRuntimeCleanup`, i18n-ключи panic/runtime)
|
||||
|
||||
### Проверка: `./gradlew build` ✓, тесты ✓ (13), `rg runtime-подсистема` → 0
|
||||
|
||||
## Фаза 3: Разбивка файлов-гигантов (пункт 3) — ЗАВЕРШЕНА
|
||||
> Стратегия: публичный фасад исключён из лимита ≤250 (делегирует ~все публичные методы),
|
||||
> вся логика вынесена в package-private final-хелперы в том же пакете (плоско),
|
||||
> ≤250 строк каждый. 0 вызывающих файлов изменено. AttributeSwap/Scaffold/ElytraTarget/
|
||||
> AutoCrystal удалены в Фазе 3.5 — их разбивать не нужно.
|
||||
- [x] Renderer2D (3379) → фасад 2150 + 14 хелперов (Blur, Chamfered, Circles, Connectors,
|
||||
Items, Lines, LiquidGlass, Orbiz, Polygons, Rects, RectsExtra, RectsGlow, SmokeFill, Textures)
|
||||
- [x] BetterChatRenderer (2870) → фасад 210 + 21 хелпер (LayoutEngine, Viewport, FrameRenderer,
|
||||
MessageRenderer, GlyphRenderer, InputRenderer, Suggestion*, InputHandler, MouseClick,
|
||||
ContextMenu, Tooltip, Draw/Font/EditBox/Segment/CommandMerge + 3 model-файла)
|
||||
- [x] UnifiedSettingRenderer (2798) → фасад 208 + 24 хелпера (по типам настроек + цвет-редактор
|
||||
из 9 классов: Renderer, SquareRenderer, PresetRenderer/Handler, HexInput/State, Drag, Layout, Math)
|
||||
- [x] ClickGuiRenderer (1887) → фасад 542 + 15 хелперов (Input, Keyboard, RenderEngine, Lifecycle,
|
||||
Screen, TextBatch, DrawHelpers, KeyBind, Editor, PickerIcon, TopLayer, TabBar, TabState + Model + Fonts)
|
||||
- [x] NameTags (1791) → фасад 438 + 11 хелперов (Renderer, Plate, LayoutEngine, Face, Equipment,
|
||||
Opponent, Effects, LabelBuilder, EnchantFormatter, Cache + Model)
|
||||
- [x] TargetHud (1655) → фасад 340 + 11 хелперов (Renderer, TargetResolver, Panel, Face, Content,
|
||||
Text, Arc, Equipment, Effects, Formatter + Model)
|
||||
- [x] ESP (1538) → фасад 269 + 11 хелперов (EntityCollector, ShaderRenderer/Mask/Color, 2D/3D,
|
||||
SearchOverlay, Outline, MeshBuilder, ColorUtil + Model)
|
||||
- [x] CustomBar (1495) → фасад 426 + 11 хелперов (XpBar, JumpBar, Locator*×7, Resolver + Model)
|
||||
|
||||
### Проверка: после каждого файла `./gradlew build` ✓; хелперы ≤250, фасады исключены;
|
||||
### 0 вызывающих файлов изменено во всей Фазе 3
|
||||
|
||||
### Долг (файлы >250 вне списка гигантов, в т.ч. новые фасады):
|
||||
### Renderer3D 433, Module 1181, LoVisualRenderPipelines 1329, DraggableHudElementRegistry 1449,
|
||||
### Predictions 1363, DynamicIsland 1255, CustomHealthBar 412, CustomHotbar 547, HudNotifier 1116,
|
||||
### Triangulator 1102, BetterButtons 1033, Themes 1012, SvgMeshBackend 1014, UiPrimitiveRenderer 1042,
|
||||
### BetterTooltips 948, CustomTabList 829, InventorySwap 1009, ModuleManager 521, LoVisual 441,
|
||||
### а также многие HUD/module/rendering-файлы 250–800 строк. Разбирать по желанию.
|
||||
|
||||
## Фаза 3.5: Удаление читерских модулей (легит-направление)
|
||||
> Пользователь решил: LoVisual — легитное приложение. Все читерские модули удаляются.
|
||||
- [x] combat: удалены AttributeSwap, AutoAnchor, AutoAttack, AutoBed, AutoBow, AutoCrystal, Backtrack,
|
||||
Criticals, ElytraTarget, Hitbox, KillAura, KTLeave, MaceKill. **Оставлены:** ProjectilePuncher,
|
||||
PvpCooldowns, Reach, RubberHand, SpearAssist, TPSSync
|
||||
- [x] movement: удалены ВСЕ модули (AirJump, AutoDodge, ElytraFly, ElytraHelper, Flight, Freeze,
|
||||
InventoryMove, NoFall, NoPush, NoStun, Parkour, Phase, ReverseStep, SafeWalk, Speed, Sprint,
|
||||
Step, Strafe, SuperFirework, TargetStrafe, Timer, Velocity, WindJump) — 22 файла
|
||||
- [x] player: удалены AirPlace, AutoArmor, AutoEat, AutoTotem, Scaffold, XCarry.
|
||||
**Оставлены:** AutoTool, ClickPearl, FakeLag, NoDelay, NoInteract, ShitDropper
|
||||
- [x] Осиротевшие хелпер-пакеты: удалены `combat/autocrystal/` (17), `combat/autobed/` (9),
|
||||
`combat/autoanchor/` (9), `util/block/scaffold/` (10), `BlockPlacer` + тест ScaffoldPlacementGateTest
|
||||
- [x] Enum-заглушки: `KillAura.RaycastMode/RotationTiming/AngleSmoothMode` → локальные enum в
|
||||
ProjectilePuncher/Reach; `Criticals.SelectionMode` → удалено; `Hitbox`-зависимость убрана из ESP
|
||||
- [x] ESP переписан под легит: новые настройки `only_visible` (LOS-луч от камеры), `skip_invisible`,
|
||||
`only_armor` (без брони не подсвечивать) — по умолчанию `true`, только для игроков
|
||||
|
||||
### Проверка: `./gradlew build` ✓, тесты ✓ (13), остатки только в комментариях/строках
|
||||
|
||||
## Фаза 4: Security (пункт 7)
|
||||
- [x] BackdoorProtection: убрать `enum`-синглтон, сделать инстансный сервис, ленивый `Minecraft.getInstance()`
|
||||
- [x] Решить судьбу StaffTracker/StaffHeuristics — согласовать и выкинуть или переписать
|
||||
(решение: StaffHeuristics удалён, StaffTracker+StaffData оставлены, переведены на INSTANCE-паттерн)
|
||||
- [x] Покрыть логику фильтров тестами (BackdoorProtectionTest, 11 тестов)
|
||||
|
||||
## Фаза 5: Модули — нормализация под правила
|
||||
- [x] Прочесать все 131 модуль: убрать `static final Minecraft` в полях (6 шт. уже найдено)
|
||||
(LegacyHudNotifier, ScissorFunction, ShitDropper, ProtocolUtil, CooldownRender, ClickGuiRenderer.MC
|
||||
+ 7 файлов-потребителей ClickGui-пакета — всё переведено на ленивый `Minecraft.getInstance()`)
|
||||
- [x] Проверить модули на `System.currentTimeMillis()` → заменить на игровое время
|
||||
(16 файлов модулей → новый `util/time/GameClock.millis()`, откат на стенное время вне мира)
|
||||
- [ ] Разнести addon/events/mixininterface по смыслу — ОТЛОЖЕНО: blast-radius 54/41/88 файлов-импортёров,
|
||||
чистый import-churn без функциональной ценности; делать только отдельным скриптовым проходом
|
||||
|
||||
## Фаза 6: Тесты (пункт 6)
|
||||
- [x] Юнит-тесты: EventBus (12), конфиг (values: NumberValue/Boolean/String/Set/ItemIdSet/BooleanMap,
|
||||
ModeValue, EnumValue, RGB/RGBA color; profile: DiffEngine, ValueCodec),
|
||||
утилиты (LegacyTextUtil — 12). Игровые классы без моков: тесты только на чистую логику.
|
||||
- [x] По мере переписи модулей — тест на каждый (Текущий итог: 292 теста, все зелёные)
|
||||
|
||||
## Фаза 7: QoL-модули из Delta (легит) — ПЛАН, обсуждаем
|
||||
> Референс: `ref/delta-26.2-main` (чит-клиент, код обфусцирован — берём только идеи,
|
||||
> переписываем под наши правила; у Delta 114 модулей, у нас 63).
|
||||
> Уже перенесено: SeeInvisibles→InvisESP, HandsShader→Chams/Noise, AtmoDawnFog,
|
||||
> ShulkerPreview, Animations, ItemPhysic (из WorldTweaks), HoldMyItems (фикс ordinal-бага,
|
||||
> математика в EaseCurves). Реф-исходники: ref/hmi-src, ref/soupvisuals-src.
|
||||
|
||||
### Решения по обсуждению (2026-09-05)
|
||||
- [x] Wasted — НЕ делаем
|
||||
- [x] TapeMouse — делаем (пользователь: AFK-фермы, не чит-софт)
|
||||
- [x] DeathCoords — делаем
|
||||
- [x] ItemScroller / Arrows — НЕ делаем
|
||||
- [x] ColorPlus ≈ наш PostFX — PostFX восстановлен и починен (дефолтный пресет был near-identity → добавлен Cinematic + тесты)
|
||||
- [x] Правило папок: максимум 4 файла (было 3)
|
||||
|
||||
### Перенос (список пользователя)
|
||||
- [x] **StreamerMode** (misc) — маскирует ники-подобные токены и названия серверов в чате
|
||||
(общая точка NameProtect: ChatComponentMixin.addMessage), скины заменяются на
|
||||
стандартные (AbstractClientPlayerMixin.getSkin). Чистая логика — `util/text/NameMasker`
|
||||
(тест NameMaskerTest).
|
||||
- [x] **LockSlot** (player, ~36) — запрет выброса из выбранных слотов 1–9 (анти-случайный Q),
|
||||
опция «только в PVP», предупреждение в чат. Точка: перехват drop-пакета.
|
||||
- [x] **AutoRespawn** (player, ~28) — авто-возрождение после смерти (tick на death screen).
|
||||
- [x] **AutoLeave** (player) — выход по триггерам: низкое ХП, N игроков в радиусе
|
||||
(друзья игнорируются опцией), задержка + предупреждение. Выход через
|
||||
`mc.disconnect(null, false)`; чистая state-machine `LeavePolicy` (AutoLeaveTest).
|
||||
- [x] **AutoAccept** (player) — авто-ответ на ТП/клан-запросы: паттерны фраз (SetValue),
|
||||
команда ответа, «только друзья» (PlayerRelations), задержка, кулдаун ≥500мс.
|
||||
Пакетный путь: PvpChatEvent (handleSystemChat/handlePlayerChat), отправка
|
||||
`connection.sendCommand(...)`. Чистый парсер `TeleportRequestParser` (AutoAcceptTest).
|
||||
- [x] **SoundReducer** (player, ~42) — РЕШЕНО: развитие NoSound, а не новый модуль.
|
||||
Режимы Mute (старое поведение через ClientPacketListenerMixin) и Reduce —
|
||||
новая громкость 1–100% через SoundEngineMixin (@Redirect getVolume в
|
||||
SoundEngine.play). Чистая математика `NoSound.resolveScale` (NoSoundTest).
|
||||
- [x] **Wasted** (misc, ~100) — ОТМЕНЕНО (решение пользователя)
|
||||
- [x] **ThirdPerson** (player) — свободный обзор от 3-го лица по удержанию бинда (L,
|
||||
настраивается), движение игрока не поворачивается. MouseMixin.turnPlayer копит
|
||||
orbit-yaw/pitch, CameraMixin.update (TAIL) ставит камеру за игрока по орбите
|
||||
(+ cull-frustum). Орбиты сбрасываются, когда бинд отпущен.
|
||||
- [x] **UseTracker** (player) — уведомления: мои тотемы (N осталось), тотемы других,
|
||||
эффекты оппонентов (группа: invisibility/speed/strength/regeneration/fire_res/weakness).
|
||||
Пакетный путь: ClientboundEntityEventPacket (event 35) + ClientboundUpdateMobEffectPacket.
|
||||
Тест UseTrackerTest.
|
||||
- [x] **NoServerPack** (misc) — авто-отклонение принудительного серверного ресурспака
|
||||
(спрашивать/отклонять всегда). Точка: ClientCommonPacketListenerImplMixin.
|
||||
- [x] **NoFriendDamage** (combat, ~19) — отмена атаки, если цель — друг (PlayerRelations).
|
||||
- [x] **TapeMouse** — ОСТАВЛЕН (решение владельца 2026-09-08): автоклик по таймеру
|
||||
(Attack/Use) для AFK-ферм, интервал, опция «только при зажатии». Считаем
|
||||
допустимым исключением из Фазы 3.5 по явному решению владельца.
|
||||
|
||||
### Кандидаты из отчёта (не в списке пользователя — обсудить)
|
||||
- [x] **DeathCoords** (~27) — координаты последней смерти в чат. Дёшево, полезно.
|
||||
- [x] **ItemScroller** (misc) — shift-перекладка предметов колёсиком в контейнерах:
|
||||
QUICK_MOVE клик по слоту под курсором, иначе слот хотбара (опция).
|
||||
Точка: AbstractContainerScreenMixin.mouseScrolled; чистая логика
|
||||
`ScrollMovePolicy` (ScrollMovePolicyTest).
|
||||
- [x] **Arrows / off-screen индикаторы** — ОТМЕНЕНО (решение владельца 2026-09-08).
|
||||
|
||||
### Проверка после каждого модуля: `./gradlew build` ✓, тесты на чистую логику ✓
|
||||
|
||||
## Фаза 7.5: Команды — префикс % + автокомплит
|
||||
- [x] Префикс команд сменён `@` → **`%`** (старый `@` принимается как legacy).
|
||||
Источник истины — `features/command/CommandPrefix` (CHAR/STRING/isCommandLike/strip);
|
||||
вызовы обновлены: CommandManager.handle/suggest, ScreenMixin (RunCommand),
|
||||
BetterChat (TabHandler, SuggestionState, SuggestionRenderer, ContextMenuHandler),
|
||||
usage-строки и RunCommand-ссылки всех команд (help/modules и др.).
|
||||
- [x] Аргументный автокомплит дополнен usage-подсказками: если `suggest()` команды
|
||||
не дал вариантов на позиции — показывается подсказка вида `%toggle <module>`
|
||||
(`features/command/UsageHints`, парсит `<...>`/`[...]` из usage-строки).
|
||||
Существующий UI автокомплита BetterChat (стрелки/Tab/клик) работает как раньше.
|
||||
|
||||
### Проверка: `./gradlew build` ✓, `./gradlew test` ✓ (CommandPrefixTest, UsageHintsTest,
|
||||
### CommandDiscoveryTest обновлён)
|
||||
|
||||
## Фаза 8: Структура «как у Delta» (маленькие файлы) — ПЛАН
|
||||
> Правило ≤250 уже есть (Фаза 1–3), но долг: ~280 файлов >250 строк.
|
||||
> У Delta файлы компактные и слои разделены: api / client(features, ui, utils, processors) /
|
||||
> inject(accessors, invokers, mixin). Наша пакетная структура сопоставима — проблема в размере файлов.
|
||||
|
||||
- [ ] Топ-гигантов к разбивке (после Фазы 3): LoVisualRenderPipelines 1527,
|
||||
DraggableHudElementRegistry 1449, Predictions 1363, DynamicIsland 1254, Module 1181,
|
||||
HudNotifier 1116, AltManager 1115, Triangulator 1102, TargetESP 1061, UiPrimitiveRenderer 1042
|
||||
- [ ] Стратегия: как в Фазе 3 — фасад + package-private хелперы ≤250, 0 изменений вызывающих,
|
||||
`./gradlew build` после каждого; по мере касания файлов в Фазе 7 — разбивать на ходу
|
||||
- [ ] Facades >250 (Renderer2D 2150 и т.п.) — оставить как есть (исключение по правилам)
|
||||
- [ ] Не смешивать с Фазой 7: разбивка структуры — отдельными проходами, иначе diff-каша
|
||||
|
||||
### Проверка: `./gradlew test` зелёный
|
||||
|
||||
## Фаза 8.5: Аудит структуры (правила ≤250 строк / ≤4 файла) — ПОЛНЫЙ СПИСОК НА РЕФАКТОРИНГ
|
||||
> Снимок 2026-09-09 (`src/main/java`, 1459 файлов / 197k строк).
|
||||
> **Гейт перед Фазой 9.2 (Optimize)**: движковые файлы, которые Optimize будет трогать,
|
||||
> должны быть разбиты заранее. 9.1 (мёртвые грузы) от этого НЕ зависит — идёт своим порядком.
|
||||
> Стратегия та же, что Фаза 3/8: фасад + package-private хелперы ≤250, 0 правок вызывающих,
|
||||
> билд после каждого файла. Фасад-исключение (Renderer2D) — см. оговорку ниже.
|
||||
|
||||
### Итоговая картина
|
||||
- **Файлы >250 строк: 244 шт.** → >800 строк: 26 | 400–800: 78 | 201–400: 140
|
||||
- **Папки >4 файлов: 86 шт.**
|
||||
- Разбивка больших файлов по зонам: features/gui — 73 | render/engine — 54 |
|
||||
features/module — 35 | util — ~35 | mixins — 3 | config — ~6 | прочее — остальное
|
||||
|
||||
### 8.5.1 Папки >4 файлов — топ должников (разбить на подпапки по смыслу)
|
||||
- [x] `mixins/` — 108 файлов → leaf-пакеты (entity/blaze3d/input/world/network/gui/hud/render),
|
||||
mixins.json перегенерирован, FQN-строки LoVisualMixinPlugin обновлены. Коммиты 94d5af7, 3c76817.
|
||||
- [x] `mixins/accessors/` — 45 → 12 групп (entity/renderer/world/state/packet/inventory/...). Коммит 3c76817.
|
||||
Также разбиты iris/sodium/security на подпакеты.
|
||||
- [x] `features/module/modules/visuals/` — 67 → esp/nametags/hats/effects/weather/scene/camera/
|
||||
player/items/fx + lyrics/lrc. Конфиг-id НЕ менялись (проверено). Коммит 073ac26.
|
||||
- [x] `features/gui/clickgui/settings/` — 51 → core/bool/mode/slider/text/text-list/group/bind/
|
||||
color(+render/preset/hex)/i18n/render/skin/owner/cooldown/util. Коммит 9db1fec.
|
||||
- [x] `features/gui/hud/draggable/impl/` — 40 → stats/metrics/status/combat/panels/targethud(+/render,
|
||||
render/parts)/scripted + hudnotifier/model, targethud-модель свёрнута в класс с record-ами. Коммит 6b89e40.
|
||||
- [x] `events/impl/` — 32 → input/movement/world/network/entity/pvp/render/player;
|
||||
events/ верхний уровень: core(Event,EventBus,Events) + meta(EventHandler,UsedImplicitly).
|
||||
- [x] `features/gui/chat/` — 31 → `render/`, `suggestions/`, `menu/`, `model/`.
|
||||
- [x] `render/engine/uniform/impl/` — 28 → по потребителям (`uniform/impl/pipeline/`, `uniform/impl/effects/`).
|
||||
- [x] `render/engine/renderer/ui/` — 28: верхний уровень разнести по уже существующим
|
||||
`draw/`, `runtime/`, добавить `blur/`, `batches/`.
|
||||
- [x] `render/engine/text/` — 24 и `text/backend/` — 12 → `text/` разбить на `text/render/`,
|
||||
`text/layout/`, `text/msdf/`.
|
||||
- [x] `render/engine/renderer/ui/runtime/script/` — 23, `ui/draw/` — 23 → внутри по 4-файловым кластерам.
|
||||
- [x] `features/gui/hud/nondraggable/impl/` — 20 (+`impl/tab/`) → `impl/overlays/`, `impl/tablist/`.
|
||||
- [x] `features/gui/clickgui/` — 20 на верхнем уровне → всё в существующие `layout/`, `sections/`,
|
||||
`picker/`, `protocol/`, `util/`.
|
||||
- [x] `render/engine/command/` — 17, `render/helpers/` — 16, `features/command/impl/` — 16,
|
||||
`config/values/` — 16 → по 2-3 подпапки (типам: `values/numeric/`, `values/color/`…).
|
||||
- [x] `render/engine/profiler/` — 14, `modules/player/` — 14, `features/gui/hud/` — 14,
|
||||
`addon/` — 14 — средние: раскидать при касании.
|
||||
- [x] Хвост (5–13 файлов, ~55 папок): `util/pvp` 13, `util/projectile` 13, `render/iris` 13,
|
||||
`config/profile` 13, `features/module/misc` 12, `features/module` 12, `config` 12 и т.д. —
|
||||
ЗАКРЫТ выделенным проходом (коммиты 8846d53…303b17e, волна 8.5.1 завершена).
|
||||
Снимок после волны: 0 папок >4 файлов в `src/main/java` (скан 2026-09-10).
|
||||
Замечания: command-discovery рекурсивный (ClassGraph acceptPackages), конфиг-id
|
||||
и ключи i18n не менялись; package-private члены, ставшие кросс-пакетными,
|
||||
расширены до public/protected; svgMsdfTool-импорты починены после split svg.
|
||||
|
||||
### 8.5.2 Файлы-гиганты >600 строк — приоритетная очередь разбивки (53 файла) — ЗАВЕРШЕНА
|
||||
> Проверено 2026-09-28: `find src/main/java -name "*.java" | xargs wc -l` → 0 файлов >600 строк
|
||||
> (самый крупный — Renderer2D.java 474, осознанный фасад-делегат, исключение Фазы 8). Все 53
|
||||
> пункта очереди ниже закрыты. Остаток структурного долга — 8.5.3 (201–400 строк), не марафон,
|
||||
> а правило «трогаешь файл по другой задаче — первым коммитом режешь до ≤250».
|
||||
> Формат: файл (строк) → идея разбивки. Порядок сверху вниз. Facade-оговорка:
|
||||
> Renderer2D остаётся фасадом-делегатом (правило Фазы 8), но его вспомогательные
|
||||
> классы (kawase/blur/glass) — вынести из OrderedUiBatcher (935) в `ui/blur/`.
|
||||
- [x] Renderer2D 2179→1597 → ГОТОВО 7b848ddd+464f7868+098c5515 (2026-09-25): ФАСАД;
|
||||
далее 1597→475 наследованием (волна 2026-09-25 ниже)
|
||||
(исключение по правилу Фазы 8) — делегирование оставлено; −234 мёртвые шимы
|
||||
совместимости, −271 недостижимые перегрузки liquidGlass/item/Corners,
|
||||
−115 прочие мёртвые перегрузки+импорты; макс. тело метода 35 строк (блоков >250 нет),
|
||||
хелперы в `renderer/renderer2d/` (Rects2D/Strokes2D/…), build+test ✓
|
||||
- [x] LoVisualAltManagerScreen 1113 → ГОТОВО (2026-09-10): 11 файлов `mainmenu/alt/`
|
||||
(Screen 132, Controller 250, Ops 59, Anims 108 + auth/ 171 + render/ 157/156/139/100
|
||||
+ render/cards/ 178/166), все ≤250, build+test ✓
|
||||
- [x] UiPrimitiveRenderer 1053 → ГОТОВО (2026-09-10): фасад 188 (коннекторы) +
|
||||
render/primitive/ 4 (Context 153, Props 158, Painter 145, GlowPainter 147) +
|
||||
render/rect/ 2 (RectPainter 150, CornerPainter 150), build+test ✓
|
||||
- [x] BetterButtons 1032 → ГОТОВО 7e86f8d: 388 + 7×≤250 (state/hover/parallax/queue/render) — extraction+tooltip+render → `hud/nondraggable/impl/buttons/`
|
||||
- [x] InventorySwap 1031 → УДАЛЁН 5acafc8: чит-инфра, заменён ShitEsp + хотбар-only — по стратегиям свапа; чистая логика отдельными классами с тестами
|
||||
- [x] SvgMeshBackend 1014 → ГОТОВО c11ebe3/aafe7de: 1020→267 + Parser181/Geometry173/Texture223/Render35/Cache25 — parser/triangulate/cache разнести
|
||||
- [x] Themes 1012 → ГОТОВО e51a610: 1012→562 + 3×163 пресета — реестр тем в `theme/presets/` (по 4 темы/файл), дефолты отдельно
|
||||
- [x] WorldParticles 991 → ГОТОВО 1d757a6+5a463d7e: 999→821→260 (фасад) + ambient/render/ 3×≤182
|
||||
(Batches/Bubble/Funnel) + ambient/particle/ 197 + geometry/ доработаны (offset/multiplyAlpha/STACKS) —
|
||||
типы частиц в `visuals/effects/ambient/`
|
||||
- [x] Module 989 → ГОТОВО c8da336: 1031→139 + 4×≤134 (ext/) — базовый класс: settings-механику вынести в `module/ext/` (осторожно, общий предок!)
|
||||
- [x] LoVisualAddonManagerScreen 989 → ГОТОВО de56b4b9: 989→112 + 10 файлов mainmenu/addon/
|
||||
(Controller 164, Ops 112, PanelAnims 56, auth/AddonScanState 52 + render/ 115/162/183
|
||||
+ render/cards/ 79/88), все ≤250 — по образцу AltManager
|
||||
- [x] Chams 976 → ГОТОВО 78b5335: 985→129 + 4×≤195 — режимы рендера в подклассы-политики (фасад модуля тонкий)
|
||||
- [x] ClickGuiPickerState 962 → ГОТОВО 234f0d9: 970→166 + 4×≤140; CombatProtocolHeuristicsEditorState 932→79 +4×≤192 03ae4a6 — state/actions хелперы
|
||||
- [x] ModulesMenuScreen 960 → ГОТОВО 234f0d9: 966→145 + 4×≤178 — рендер категорий отдельно от состояния
|
||||
- [x] UiMeshGeometry 954 → ГОТОВО sub-agent: 956→99 + 4×≤135 — по примитивам геометрии
|
||||
- [x] BetterTooltips 953 → ГОТОВО 0b8ea41: 960→181 +4×≤199; OrderedUiBatcher 935 → ГОТОВО 6e0bf7f: 944→188 (→ batches + blur), Scoreboard 924,
|
||||
Rain 893 → ГОТОВО sub-agent: 903→159 +4×≤162; CompactHudStatModel 893 → ГОТОВО 8979140: 894→125 +4×≤175; TazikHat 859 ✓ ✓ закрыт портированием в
|
||||
Cosmetics built-in (2026-09-11, см. Фазу 7),
|
||||
ShaderPatchCompiler 843 → ГОТОВО 2b55fbc: 843→66+4×≤174; OnlineRelationPlayerPickerComponent 837→74+4×≤192 17fc665; CustomTabList 830 → ГОТОВО 9e9b71d: 834→136+4×≤166,
|
||||
GameRendererMixin 830 → ГОТОВО f45f46e: 830→344 (только хуки+Host/BobInvoker) +
|
||||
mixins/render/main/handlers/ 4×≤250 (ProjectionHelper/WorldEngine/WorldPost/WorldDebug), Predictions 805 → ГОТОВО (см. хвост)
|
||||
- [x] Хвост 400–800: ЧАСТИЧНО — ConfigProfilesComponent 799→125 d95bfec, HitEffect 770→120 5e1ce05, PlayerMovementSimulation 757→157 4b253d1, ProjectilePuncher 747→104 fba4f7d, MediaPlayer 739→174 8ce3927, Crosshair 737→143 4cf0970, Itemizer 699→193/Cooldowns 707→151 11245ac, Potions 617→176 2e4912a, Admins 702→118 f18cb96, Keybinds 611→157 cc18feb, ModuleList 499→181 52327dc, Radar 392→161 f580373, Fps 309→168 5df2cc0, Memory 304→151 de7cc2b, Tps 339→154 e167f0c, Ping 309→145 15d04cb, SpeedBps 334→156 b78a638, GameTime 314→150 922d3bc, SystemTime 300→136 3cb6e40, Coordinates 337→174 9412982, Armor 324→162 932639d, Inventory 476→153 f4124f4, BlockHighlight 692→271+4×≤250 9f76e5a5 (scene/highlight/), см. /tmp-снимок в истории команд: ConfigProfilesComponent 791,
|
||||
HitEffect 770, PlayerMovementSimulation 755, ProjectilePuncher 746, MediaPlayer 739,
|
||||
Crosshair 737, MeshBuilder 729, SettingsPanelComponent 703, Admins 702, Cooldowns 701,
|
||||
Itemizer 699, LocalPlayerMixin 687, Trails 681, ItemBatchRenderer 673,
|
||||
CosmeticModel 671, MenuScreen 667, GlStencilFramebufferSupport 663, ConfigSerializer 663,
|
||||
DynamicIsland 661, ReimaginedVisual 660, RelationsComponent 659, UiStyle 640, Potions 617,
|
||||
ModuleComponent 612, MsdfFont 606, Keybinds 603, MediaSessionService 594 и др.)
|
||||
- [x] Волна 2026-09-24 (HUD/settings): NameTags 453→223 (nametags/impl/ Keys+Collector+Projection) b1414d7a,
|
||||
MainSettingsComponent 448→291 (+Scroll+Model) 3f0d452f, LinuxMediaSession 441→148 3d11bb4e,
|
||||
CustomTextRenderer 229→195 (GradientTexts), CustomBar 438→185 (+CustomBarModel константы
|
||||
+bars/impl/BarSettings, forwardеры удалены, mixins правлены на реальные рендереры) 4e442fb7,
|
||||
CustomHealthBar 406→146 (+bars/impl/HealthBarPainter+HealthScriptRenderer, dead-code удалён) a3415435.
|
||||
bars/ закрыт: все файлы ≤250, impl/ = 3 файла.
|
||||
- [x] Волна 2026-09-25 (реестры): Renderer2D 2179→1597 (см. строку 8.5.2 выше,
|
||||
7b848ddd+464f7868+098c5515), LoVisualRenderPipelines 432→311 9577ed3d
|
||||
(SHADER_* 105 констант → pipeline/ShaderIds 130, мёртвые импорты удалены;
|
||||
логика ≤26 строк, фасад-исключение), build+test ✓.
|
||||
- [x] Волна 2026-09-25 (наследование фасада): Renderer2D 1597→475 (leaf), цепочка
|
||||
Renderer2D extends Item(124) extends Glass(183) extends Shapes(313) extends
|
||||
Path(245) extends Rounded(315) extends Base(23); API вызывающих не менялся
|
||||
(0 правок callers, static-import ITEM_OVERLAY_* и Renderer2D.LiquidGlassPreset
|
||||
работают по наследству); renderer/facade/ = 4 файла, facade/shape/ = 2 ✓ лимит;
|
||||
Base.self() для this→Renderer2D-делегатов; семейства >250 — фасад-части
|
||||
(исключение TODO:Facades); 37c7c410+64e23ef8+9ae72eeb+2b8c40a3+6204017b+
|
||||
9fffc58d, build+test+checkstyle ✓.
|
||||
|
||||
### 8.5.3 201–400 строк (~140 файлов) — проход «касаться = чинить»
|
||||
- [x] Волна 2026-09-30 (топ-4 по объёму, выбор владельца): каждый файл — отдельный
|
||||
коммит, фасад + хелперы, публичный API и вызывающие не тронуты:
|
||||
ConfigDiffPanelComponent 393→167 (фасад + `config/diff/`
|
||||
HeaderRenderer/BodyRenderer/Scrollbar) 7e8b6b64;
|
||||
RotationManager 391→199 (фасад + `rotation/internal/`
|
||||
RotationState/RotationStepper/RotationPackets, @EventHandler-методы остались
|
||||
на фасаде — регистрация не менялась) 3e57542b;
|
||||
ScriptedTriangulatorHudPanel 386→189 (записи Panel/Palette остались
|
||||
вложенными → `ScriptedTriangulatorHudPanel.Panel` у вызывающих работает,
|
||||
рендер → ScriptedTriangulatorRenderer, пропсы/сигнатуры → `scripted/panel/`)
|
||||
76bd8e95; BetterButtons 381→197 (настройки → ButtonStyleSettings по образцу
|
||||
BarSettings, иконки → ButtonIconRenderer, −3 мёртвых метода без вызовов)
|
||||
71f779c8; `./gradlew build` ✓ test 438 ✓ checkFolderLimit ✓ на каждом шаге;
|
||||
файлов >250: 155 → 151
|
||||
- [ ] Не делать отдельным марафоном: при правке файла по другой фазе — первым коммитом
|
||||
разбивка на ≤250, вторым — целевое изменение (иначе diff-каша, правило Фазы 8)
|
||||
|
||||
### 8.5.4 Порядок и критерии готовности
|
||||
- [x] Очередь: 8.5.1 крупные папки (mixins, visuals, clickgui/settings, hud impl) →
|
||||
8.5.2 гиганты >800 → далее по убыванию. Каждая папка/файл = отдельный коммит.
|
||||
→ 8.5.1 ЗАВЕРШЕНА (2026-09-10): 0 папок >4 файлов.
|
||||
- [ ] После каждого шага: `./gradlew build` ✓, `./gradlew test` ✓; конфиг-id модулей и
|
||||
ключи сеттингов НЕ ломаем (совместимость .lvcfg); скрипты-переименования пакетов
|
||||
не трогают string-literals id — проверять grep'ом после moves.
|
||||
- [x] Критерий закрытия для старта 9.2: ноль файлов >250 в путях, которые трогает Optimize
|
||||
(Renderer2D-секции blur/glass, OrderedUiBatcher, HUD-имплементации bgEffect,
|
||||
MsdfFont/CustomTextRenderer), и ноль папок >4 в этих же пакетах. Полный ноль по всем
|
||||
244/86 — долгий хвост, 9.2 им не блокируется.
|
||||
→ 2026-09-24: MsdfFont 155 ✓, OrderedUiBatcher 180 ✓, CustomTextRenderer 229→195 ✓
|
||||
(glyph-pass вынесен в GradientTexts). Остаток гейта — bgEffect-HUD >250:
|
||||
TargetHud 333, Triangulator 304, Scoreboard 233 — ВСЕ фасады Фазы 3 с helper-пакетами
|
||||
(targethud/render/, triangulator/{render,Solver,Tracker,View}, panels/scoreboard/) →
|
||||
исключение по правилу фасадов. ГЕЙТ 9.2 ЗАКРЫТ.
|
||||
- [ ] Обновить этот раздел при подходе к волне (отмечать [x], доснимать счётчики).
|
||||
|
||||
## Фаза 9: Оптимизация FPS (аудит 2026-09-09) — ПЛАН
|
||||
> Диагноз: у клиента есть «налог каждый кадр», который платится даже при
|
||||
> выключенных модулях (друг: просадка FPS на чистом конфиге, Windows).
|
||||
> Решение владельца: мёртвые грузы — ЧИНИМ в коде; авто-деградации по FPS НЕ ДЕЛАЕМ;
|
||||
> вместо неё — ручной модуль **Optimize** (включил → порезали стекло/blur/тяжёлые проходы).
|
||||
|
||||
### 9.1 Мёртвые грузы (чиним в коде всегда, независимо от модулей) — приоритет
|
||||
- [x] **glGetError на каждом render pass** — ГОТОВО 2026-09-11 (коммит 137121e): гейт по clip-active || stencil-debug — `GlStencilShapeClipBackend.endRenderPass`
|
||||
(`:140`) и `beginRenderPass` (`:98/:104/:110/:124`) дёргают `checkGlErrors`
|
||||
(`:428-434`) на КАЖДЫЙ проход (миксин `CommandEncoderMixin:102-112` висит на
|
||||
submitRenderPass → это и все ванильные/Sodium проходы). `glGetError` = синк с
|
||||
Windows-драйвером, несколько мс/кадр. Фикс: проверять только если
|
||||
`mode != DISABLED || renderPassAttachmentRequired`, либо под dev-флагом;
|
||||
в релизе — no-op.
|
||||
- [x] **`lovisual$isVulkanBackend()` на каждый submitRenderPass** — ГОТОВО (c9a65b1), кэш Boolean
|
||||
(`CommandEncoderMixin:66-74`) — `getDeviceInfo().backendName().toLowerCase()`
|
||||
заново на кадр×проходы. Фикс: вычислить один раз, закэшировать boolean.
|
||||
- [x] **PreTranslucentDepth — полноэкранный depth-copy каждый кадр без нужды** — ГОТОВО (b747b47): demand-флаг requestForNextFrame()/takeRequested() у потребителей getDepthView(For), capture в vanilla- и Iris-путях по запросу (1-кадр задержка, фолбэк на live view) —
|
||||
`LevelRendererMixin:275-283` гейтится только «нет Iris». Фикс: гейт
|
||||
`needsPreTranslucentDepthCapture()` по образцу `WorldSceneDepth`
|
||||
(`LevelRendererMixin:160-218`); потребители: `PostProcessGraphResources:61`,
|
||||
`LoVisualVisuals:79`, `WorldPassCompiler:174`.
|
||||
- [x] **copyMainColor каждый кадр ДО гейта** — ГОТОВО (118eb2a): ранний return по liquidGlassBlurRequested — `UiBlurResources.captureWorldSource`
|
||||
(`:116-123`): полноэкранный color-copy делается до проверки
|
||||
`liquidGlassBlurRequested` (`:122`). Фикс: переставить — copy только когда
|
||||
blur реально запрошен.
|
||||
- [x] **Строки профайлера при выключенном профайлере** — ГОТОВО (aa55c4d): 2-arg scope()/section()/postPass(prefix,suffix) в ProfilerPhase/RenderProfiler2D/3D/RenderCostProfiler (DEV-гейт конкатенации) — конкатенации на call-site
|
||||
каждый кадр: `PostProcessGraph:59` (`"graph:" + phase`), `ModuleManager:219-221/:290`
|
||||
(`"3d:module:" + m.name()`). Фикс: ленивые supplier'ы / ранний isEnabled-check.
|
||||
- [x] **Мелочь, по ходу**: `new PoseStack`/`new Matrix4f`×2/`new Frustum`×2 в кадре
|
||||
(`GameRendererMixin:203-239/:341-347/:609-617`) — закэшить переиспользуемые копии. Frustum-часть отменена (da6525c): @Invoker("calculateFrustum") не резолвится на remapped-рантайме = краш; scratch-кэш матриц/PoseStack остался.
|
||||
|
||||
### 9.2 Модуль **Optimize** (misc) — ручной рубильник экономии
|
||||
> ⛔ ГЕЙТ: не начинать, пока не закрыта Фаза 8.5 (рефакторинг-аудитStructure ниже) —
|
||||
> Optimize трогает Renderer2D/OrderedUiBatcher/HUD-имплементации, все они >250 строк.
|
||||
> Идея владельца: один тумблер «хочу максимум FPS». НИКАКОГО автовключения по FPS.
|
||||
> Пока активен — движок и HUD работают в лёгком режиме.
|
||||
- [x] Каркас — ГОТОВО ade82f06: `modules/misc/optimize/Optimize.java` (в подпапке — лимит
|
||||
`checkFolderLimit`=4 файла в `misc/`), 4 bool-тумблера (default on), тест `OptimizeTest`.
|
||||
Отклонения от плана при реализации:
|
||||
- `no_glass` — **9246e010**: не мутим bgEffect (конфиг элементов неизменяем) и не
|
||||
ищем `prepareSharedBlurInternal` (метода больше нет). Гейты: `Blur2D.source()`
|
||||
→ null при no_glass (вместе с `FpsOptimizer.isGlassDisabled` → все
|
||||
blurRect/glassBlurRect мимо) + ранние return в
|
||||
`UiBlurResources.requestLiquidGlassBlur/requestBeforeNextShapeClip`
|
||||
(флаги не ставятся → `captureWorldSource` early-out → нет полноэкранного copy,
|
||||
kawase и prewarm). Побочный эффект: гасит и низкоконтрастный blur текста
|
||||
(осознанно — «все glass/blur off» по замыслу тумблера).
|
||||
- `lite_post` — **52463246**: MotionBlur `SHADER_TAPS=11 → LITE_TAPS=5`
|
||||
(план «15→5» устарел — тапов уже 11), `historyValid = hasHistory && !lite`
|
||||
(temporal-история off = ещё ~10 neighborhood-fetch'ей в шейдерный early-out;
|
||||
цена: хвостов истории нет, остаётся чисто пространственный блюр).
|
||||
DoF: `taps = lite ? LOW : quality` (LOW=8; план «32→8» устарел — квалити 8/14/22).
|
||||
- `lite_sky` — **c403cb53**: `SkyboxShaderPasses.LITE_SKY_LAYER_MASK=0xF00F`
|
||||
(базовые слои 0-3 + звёзды 12-15, чистит nebula/curtains/bursts/polar/water/…),
|
||||
`layerMask &=` при включённом тумблере; в `reimagined_skybox.frag` 4
|
||||
безусловных fbm + bursts-блок обёрнуты в layer-гейты; pulsar/thunder fbm
|
||||
4→2 октавы через `u_Thunder.w` (ранее всегда 0). Тест `SkyboxShaderPassesTest`,
|
||||
все 3 шейдера проходят `glslangValidator -S frag`.
|
||||
- `lean_hud` — **8746074d**: `GradientTexts.horizontalFade` → plain-текст (без
|
||||
per-glyph `new String` и gradient-лямбд; marquee-clipped — clip остаётся,
|
||||
fade-градиенты гаснут) в Custom и Vanilla-реализациях; Fps digit-roll off
|
||||
(`FpsAnimator.widerText/digitProgress` → settled-прогресс).
|
||||
Не в scope: `GradientTexts.gradient/quadGradient` (визуал списка модулей),
|
||||
аллокация в `HorizontalFade.glyphAdvance` (её просто обходит lean-гейт).
|
||||
- [x] Механизм «как движок узнаёт» — ГОТОВО ade82f06: `render/engine/optimize/
|
||||
OptimizeState.java` (enum-utility, 4 volatile boolean, `apply/reset`, чистый
|
||||
статик без состояния модуля), движок читает геттеры, модуль пушит в onEnable/onFrame.
|
||||
- [x] Взаимодействие с 9.1 — ГОТОВО: фиксы 9.1 не завязаны на Optimize и продолжают
|
||||
работать всегда; Optimize только добавляет гейты сверху.
|
||||
- [x] i18n en_us + ru_ru — ГОТОВО ade82f06: `module.optimize.*` + `setting.optimize.*`
|
||||
(4 ключа, симметрия en/ru проверена).
|
||||
- [x] Нотификация при первом включении — ГОТОВО ade82f06: once-флаг в инстансе модуля +
|
||||
`Notifier.info("Optimize: " + summaryOf(...))` со списком включённых тумблеров.
|
||||
|
||||
### 9.3 Проверка
|
||||
- [x] `./gradlew build` после каждого пункта; `./gradlew test` зелёный — выполнялось
|
||||
после каждого из 5 коммитов 9.2 (итог: build+test зелёные на 8746074d).
|
||||
- [x] Порядок: 9.1 (по одному пункту на коммит) → 9.2 — 9.1 закрыта (коммиты выше),
|
||||
9.2 закрыта ade82f06..8746074d.
|
||||
- A/B-мерка до/после 9.1 на чистом конфиге — УБРАНА из плана (решение владельца
|
||||
2026-09-25): требует запуска Minecraft на Windows, задачей не является.
|
||||
|
||||
## Фаза 10: Заход по референсам из Downloads (2026-09-15)
|
||||
> Пользователь скинул 5 архивов, распакованы в `/tmp/lv_refs/`. Порядок выполнения —
|
||||
> сверху вниз, по одному коммиту на пункт, `./gradlew build`+`test` после каждого.
|
||||
|
||||
- [x] **TrollfaceMask — почини "не работает"** (`/tmp/lv_refs/trollface/TrollfaceMask.java`,
|
||||
референс Moonward 1.21.4). Наш модуль: `trollface/TrollfaceMask.java`.
|
||||
Причина бага: биллборд ориентировался к камере (headPos→camPos); от первого лица
|
||||
камера почти совпадает по вертикали с головой → view≈(0,1,0) → right=view×up
|
||||
вырождается в 0 → плоскость ложится горизонтально, не видна. Режимы «Друзей»/«Всех»
|
||||
брали только `TargetManager.getTarget()` (сущность под прицелом) — маска рисовалась
|
||||
максимум на одном игроке. Фикс: ориентация по `getViewVector()` цели вместо камеры,
|
||||
перебор `mc.level.players()` с фильтром по режиму (себя/friend via PlayerRelations/все).
|
||||
Коммит e7e0ff2.
|
||||
- [x] **Rain — сравнение с двумя референсами (2026-09-15, вывод: у нас уже паритет/лучше).**
|
||||
Референс 1 `/tmp/lv_refs/rainfull/` (Cataclysm client) — наш `Rain.java` (894 строки) уже
|
||||
является портом этого самого класса (Drop/Splash/Droplet/Mist, presetDensity/Wind/Mass,
|
||||
drawStreaks/Ripples/Crowns/Droplets/Mist — 1:1 совпадающие константы и формулы), ПЛЮС у нас
|
||||
уже есть их wet-surface screen-space постфх (`WetSurfaceUniforms` + шейдер
|
||||
`assets/lovisual/shaders/wet_surface.frag`, pipeline `WET_SURFACE`, реализован как
|
||||
`PostProcessPass` в самом Rain.java) — то, что в референсе вынесено в отдельный
|
||||
`WetSurfaceRenderer`. Улучшать нечего, разница только в деталях реализации пайплайна.
|
||||
Референс 2 `/tmp/lv_refs/mokriyzhir/` (`WetWorld.java`, пакет `ez.redux...`) — это ОТДЕЛЬНЫЙ
|
||||
самостоятельный модуль "Wet World" из чужого чит-клиента (Category.VISUALS), НЕ часть дождя:
|
||||
мочит поверхности всего мира по реальной погоде сервера (`world.getRainGradient`), с настройками
|
||||
Quality (Low/Medium/High/Ultra), Reflection Strength, Wet Darkening, Ripples+Speed, Rain Only,
|
||||
учитывает fog (интеграция с их модулем Fog: цвет/дистанция/яркость подмешивается в шейдер).
|
||||
У нас такого отдельного всегда-активного "мир мокрый по факту дождя" модуля нет — наш
|
||||
wet-surface жёстко завязан на модуль Rain (работает только когда сам Rain включён). Если
|
||||
захочется — можно вынести wet-surface-pass Rain.java в отдельный модуль WetWorld с привязкой
|
||||
к `mc.level.isRaining()`+`getRainLevel(tickDelta)` вместо `onlyWhenRaining` галки, плюс quality-
|
||||
пресет и fog-интеграцию. НЕ делаю сейчас — не запрошено явно, чисто идея на будущее.
|
||||
- [x] **WetWorld — сделан отдельным модулем (2026-09-15), шейдер Rain пофикшен.**
|
||||
Третий референс `/tmp/lv_refs/wetworld2/` (`ru.white...WetWorld` + `WetWorldPipeline` +
|
||||
`wet_world.vsh/fsh`) оказался технически лучшим из трёх: настоящий SSR с бинарным
|
||||
рефайнментом пересечения, dithering шага луча, реконструкция нормали по соседям в буфере
|
||||
глубины (не screen-space derivative), fbm-небо с sun specular, fog-интеграция.
|
||||
Проверка нашего `wet_surface.frag` («может кривые») подтвердила баги:
|
||||
1) `normalFromDepth` считался через `dFdx/dFdy(viewPos)` — рвётся на границах глубины,
|
||||
шумная/блочная нормаль -> дрожащий край лужи;
|
||||
2) рябь была МЁРТВЫМ КОДОМ: `vec2 perturb = vec2(dFdx(ripple), dFdy(ripple)) * 0.0;` —
|
||||
умножение на 0 полностью гасило эффект, а сама рябь добавлялась как сырое значение
|
||||
волны вместо её наклона.
|
||||
Исправлено (коммит 0986ce6): нормаль — по 4 соседям depth-буфера (как в лучшем референсе),
|
||||
рябь — аналитический градиент (разность высоты в соседних точках), добавлен bin-рефайнмент
|
||||
SSR-пересечения + dithering, небо дополнено fbm-облаками/sun specular, затемнение вынесено
|
||||
в отдельный uniform-параметр (было зашито константой 0.82). Новый модуль
|
||||
`visuals/weather/WetWorld.java`: мочит мир по факту `mc.level.getRainLevel()`, независимо
|
||||
от партиклов Rain и тумблера `wet_ground` — Quality (Low/Medium/High/Ultra
|
||||
-> шаги/дистанция/bin-рефайнмент SSR), Reflection Strength, Darkening, Ripples+Speed,
|
||||
Rain Only. Переиспользует общий `WET_SURFACE` pipeline/`WetSurfaceUniforms` (при
|
||||
одновременном включении с Rain.wet_ground эффект стекается — задокументировано в коде).
|
||||
Fog-интеграция референса НЕ перенесена (нет доступа к чужому Fog-модулю/данным тумана в
|
||||
нашем движке в этом заходе) — можно добавить отдельно, если понадобится.
|
||||
- [x] **HitEffect — интегрирован ImpactRing (2026-09-15).**
|
||||
Референс `/tmp/lv_refs/myasomaybe/ImpactRing/` — `JumpWaveRenderer`/`JumpGlowRenderer`
|
||||
используют захват сцены + собственный низкоуровневый RHI-пайплайн с SSR-варпом
|
||||
(кастомный шейдер сэмплит скопированный цветовой буфер по кольцу) — тяжеловесная техника
|
||||
не в духе нашего движка. Взята идея визуала (кольцо + вертикальная стена свечения в точке
|
||||
удара), реализована через уже существующий движок: обычные аддитивные квады через
|
||||
MeshBuilder/Renderer3D (`WORLD_COLORED_ADDITIVE` + `WORLD_COLORED_ADDITIVE_DEPTH`), без
|
||||
захвата сцены и отдельного шейдера — консистентно с уже существующими WaveEffect/HitBubble
|
||||
в том же файле. Новый `EffectMode.IMPACT` в HitEffect: настройки impact_radius/
|
||||
impact_duration/impact_glow_height, срабатывает как WAVE (по удару), рендер —
|
||||
расширяющееся кольцо (сегменты, fade к центру) + полупрозрачная вертикальная стена по
|
||||
контуру круга, затухающая вверх и во времени. Коммит 61c478e. `./gradlew build`+`test` ✓.
|
||||
- [x] **HoldMyItems — сверка с HMI.zip (2026-09-15), портирован grip.**
|
||||
Референс `/tmp/lv_refs/hmi/` (Delta HMI): обёртка обфусцированного чит-клиента над
|
||||
СТОРОННИМ jar-модом `holdmyitems-5.1.1+26.2.jar` + свои `SwingAnimations` (десяток стилей
|
||||
свинга, weapon-inspect a-la CS:GO karambit-spin, "Pretty Hands" режимы). Полный набор
|
||||
НЕ портирован — фича-крип чит-клиента, конфликтует с KISS и с правилом «без стороннего
|
||||
jar в сборке» (Delta буквально бандлит чужой мод и синхронизирует с ним настройки через
|
||||
`OriginalHmiBridge`). Взята единственная самоценная и самодостаточная идея — **grip**:
|
||||
лёгкий хват предметов с рукоятью (мечи/инструменты/трезубец/палки — см.
|
||||
`heldAlongTheFist`) наискось в кулаке вместо строго вертикального ванильного equip.
|
||||
Портировано (коммит b257fde): `HeldAlongFist` (чистый предикат по ItemTags),
|
||||
`HandPoseMath.grip(...)` (чистая функция позы, тесты gripMirrorsBySide/
|
||||
gripExtendPullsCloserAndAcrossFist), настройки `grip_enabled`+6 slider'ов в HoldMyItems
|
||||
(дефолты как у референсного пресета "HMI"), проброс ItemStack через
|
||||
HandDynamics.apply/ItemInHandRendererMixin. `./gradlew build`+`test` зелёные.
|
||||
|
||||
### Проверка после каждого пункта: `./gradlew build` ✓, `./gradlew test` ✓, TODO.md отмечен
|
||||
|
||||
## Фаза 11: Аудит attack-визуалов — sacura 1.21.4 / GodWeer-26.2 (2026-09-22)
|
||||
> Референсы распакованы в `ref/clients/sacura-1.21.4` и `ref/clients/godweer-26.2`.
|
||||
> Вывод по большинству эффектов: у нас уже полноценнее (больше режимов, единый render
|
||||
> engine с пайплайнами вместо сырого GL). Ниже — конкретные пробелы, которые реально
|
||||
> стоит доделать; всё остальное сравнённое (HitEffect, KillEffect, JumpCircles,
|
||||
> Hitboxes, ItemPhysic, ViewModel offsets) уже на уровне или выше обоих референсов.
|
||||
|
||||
- [ ] **ViewModel — `Custom` swing-режим.** sacura (`SwingAnimationsModule`) имеет режим
|
||||
"Кастомная" с explicit слайдерами угол/наклон. У нас 8 фиксированных пресетов
|
||||
(Hammer/Chop/Arc/Double/Shake/Shove/Slash/Thrust), но нет свободно настраиваемого.
|
||||
Также посмотреть на sacura-режимы "Возвращение"/"Возвращение к себе"/"Круговая" —
|
||||
возможно стоят портирования как доп. пресеты, если визуально отличаются от наших.
|
||||
- [ ] **HitEffect — опциональный distortion/glow post-process слой.** godweer
|
||||
(`HitEffectRenderer`) кроме частиц кладёт screen-space distortion+glow слайдеры.
|
||||
У нас частицы (Bubbles/Wave/Impact) есть, post-process слоя над ними нет.
|
||||
Паттерн `PostProcessPass` уже есть (см. `TotemFX`), можно переиспользовать —
|
||||
но не блокирующая, это чистый апгрейд поверх уже более богатой системы.
|
||||
- [ ] **JumpCircles — easing на появление/исчезновение.** sacura использует `EaseBackIn`
|
||||
для пружинки при спавне/затухании круга. Проверить `render/engine/math` — если
|
||||
easing-утилита уже есть, подключить к текущему fade вместо линейного.
|
||||
- [ ] **KillEffect — through-walls поведение.** godweer (`PopEffect`) имеет явный toggle
|
||||
"сквозь стены" для beam/взрыв эффектов. Проверить DepthMode у Lightning/Orthodox
|
||||
beam в нашем KillEffect — сейчас режется окклюзией или нет; если режется и это не
|
||||
специально — добавить toggle по аналогии.
|
||||
- [ ] **Trails — живое сравнение с sacura `TrailsModule`.** Не разбирался по коду в этом
|
||||
заходе (визуальный эффект, решение лучше принимать посмотрев обе стороны вживую
|
||||
в игре, а не по исходникам).
|
||||
|
||||
### Итог: ничего из sacura/GodWeer не даёт принципиально нового attack-эффекта,
|
||||
### которого нет в LoVisual — расхождение только в деталях полировки (easing-кривые,
|
||||
### доп. custom-режим свинга, возможный distortion-слой, through-walls toggle).
|
||||
|
||||
---
|
||||
|
||||
## Фаза 12: Сессия 2026-10-01 — новые визуалы, меню, войсчат, автокомплит, починка запуска
|
||||
|
||||
> Всё за 2026-10-01 лежит ОДНИМ коммитом (владелец просит большие коммиты, не дробить).
|
||||
> Игру в этой сессии запускал только владелец (`./gradlew runClient`, из каталога `mod/`);
|
||||
> всё, что ниже помечено «не проверено вживую», видно только в игре.
|
||||
|
||||
### Сделано (код + сборка + тесты зелёные)
|
||||
|
||||
- [x] **TargetESP: режим «Созвездие» (`constellation`).** Звёзды на наклонной орбите вокруг цели,
|
||||
линии между ними, свечение. Настройки: звёзды 3-24, скорость, орбита, линии, размер, свечение.
|
||||
Геометрия: `combat/targetesp/constellation/` (чистый `ConstellationLayout` + тест).
|
||||
`TargetESP.java` сокращён 329 -> 161 строка: отслеживание цели и плавное появление вынесены в
|
||||
`targetesp/state/TargetEspTracker`, цвет в `TargetEspColors`.
|
||||
- [x] **JumpCircles переписан** (саб-агент): 7 новых форм (Звезда, Ромб, Спираль, Гексагон, Волна, Крест,
|
||||
Лепестки) к Круг/Кольцо/Пузырь; общая анимация (`CircleAnimation`), цвета из `AnimatedRenderColors`,
|
||||
круг рождается на высоте земли в момент отрыва. Пакет `visuals/player/jumpcircles/` (+ `shape/`),
|
||||
12 тестов. Удалены настройки `ease_out`, `pulse_max_size`, `pulse_lifetime`.
|
||||
- [x] **KillEffect: режим «Кровь» (`Blood`)** по архиву killeffect.zip. Капли с физикой, отскоки от стен,
|
||||
лужи на полу, мелкие брызги; через наш `Renderer3D`. Пакет `hits/blood/`. Настройки: цвет,
|
||||
количество, сила, размер, время пятен, тип разлёта (Burst/Fountain/Ring/Spray; Spray летит от атакующего).
|
||||
- [x] **Главное меню: тип «Hero»** по архиву Ya_Lublu_Femboyev.zip (экран Vectra). Настройка `menuLayout`
|
||||
(`orbs` по умолчанию / `hero`), фабрика `MainMenus`, экран `mainmenu/hero/*`, чистая логика
|
||||
`hero/logic/*` + тесты. Интеграция: альт-менеджер, `SkinManager`, наши темы.
|
||||
- [x] **Simple Voice Chat (мягкая совместимость, API 2.6.24).** `compat/voicechat/` (плагин, `VoiceState`,
|
||||
`VoiceChatBridge`), модуль `VoiceChat` (кольца и свечение над говорящими, крест при заглушённом
|
||||
микрофоне), HUD-панель `VoiceChat` (список говорящих + статус микрофона). API не попадает в jar.
|
||||
- [x] **`render/engine/renderer/quick/Draw`** — короткий статический фасад над `Renderer2D.COLOR`
|
||||
(rect, gradient, outline, glassOutline, shadow, circle) + `GlassEdge` с тестом.
|
||||
Заметка: у `Renderer2D` короткие перегрузки `roundedRect(x,y,w,h,radius,argb)` уже были,
|
||||
6-й параметр в длинной форме это `softness`, а не alpha.
|
||||
- [x] **Автокомплит `%` в ванильном чате** (как `#` у Baritone): `mixins/gui/chat/CommandSuggestionsMixin`
|
||||
подставляет наши команды/аргументы в попап по мере набора; подсказки аргументов `<module>`
|
||||
показываются серым текстом (`command/suggest/SuggestionPlan` + тест). BetterChat идёт своим путём.
|
||||
Раньше подсказки работали только в BetterChat.
|
||||
- [x] **Починка запуска клиента** (игра не стартовала с августа, все пять дефектов найдены по логам runClient):
|
||||
1. MixinExtras 0.5.4 падал на `@Redirect(at=...)` (ClassCastException при трансформации `Entity`):
|
||||
добавлен `mixinextras-fabric:0.5.5` (`gradle.properties: mixinextras_version`), вшит в jar через `include`.
|
||||
2. `ServerPolicyClient`: `registerReceiver` -> `registerGlobalReceiver`.
|
||||
3. Классы-помощники лежали в пакете миксинов (Mixin запрещает их загрузку): вынесены в
|
||||
`render/gamerenderer/` и `mixinhandlers/{player,hud,network}/`, сделаны public.
|
||||
4. `Themes`: `STORE` создавался раньше таблицы пресетов (NPE в `ThemeStoreIO`): порядок инициализации.
|
||||
5. `MainSettingsComponent.rebuildSettings`: `addAll` в неизменяемый список (падал ClickGUI по RShift).
|
||||
- [x] 2026-10-01 все сегодняшние коммиты объединены в один по просьбе владельца.
|
||||
|
||||
### Отзывы владельца после запуска (скриншоты) — СДЕЛАНО в коде, ждёт проверки в игре
|
||||
|
||||
Итог 2026-10-01 (сборка и тесты зелёные, визуально не проверено):
|
||||
- Тултипы: причина была в том, что пользовательский тултип, перекраска, фон и предпросмотр шалкера читали
|
||||
собственные флаги и игнорировали переключатель модуля; теперь везде `isEnabled()`. Синие строки
|
||||
категорий красятся акцентом темы (`TooltipColors`). Тема по умолчанию `Themes.THEME_DEFAULT = AMBER`
|
||||
(сохранённый выбор пользователя не трогается).
|
||||
- Описания: ~98 новых ru/en (62 модуля, 36 HUD), `HudElement.getDescription()`, тест `DescriptionCoverageTest`.
|
||||
- ClickGUI: плашка с описанием над колонками (`ModulesMenuDescriptionRenderer`, `DescriptionLines`).
|
||||
- Hero-меню: раскладка по измеренному тексту (`TextMetrics`, `HeroLayout`), кнопки с SVG-иконками
|
||||
(`hero/paint/*`), orbs: подпись под кнопкой при наведении (`MainMenuHoverLabel`).
|
||||
- Настройки меню: новая категория «Главное меню» (`MAIN_MENU`), `MainConfig.getMainMenuSettingDefs()`.
|
||||
- Проверить в игре: положение и размер плашки описания, вид amber-тултипа, иконки и шрифты Hero-меню,
|
||||
подпись orbs-кнопок, видимость настроек (`visibleWhen`) в категории «Главное меню».
|
||||
|
||||
Задача А (форк, тултипы и описания):
|
||||
- [x] **Тултип предметов (BetterTooltips) странный и не выключается.** При первом запуске у камня синие
|
||||
строки-категории («Building Blocks / Natural Blocks») и тёмная панель; даже при выключенном модуле
|
||||
стиль остаётся. Нужно: найти причину (миксин `mixins/hud/tooltips/TooltipRenderUtilMixin`
|
||||
применяет стиль без проверки включённости / кэш `TooltipState`), при выключении тултип строго
|
||||
ванильный, при включении аккуратный стиль. **Стиль по умолчанию должен быть `amber`, а не `classic`**
|
||||
(`Themes.THEME_AMBER`); старые конфиги не ломать.
|
||||
- [x] **Нет описаний модулей и HUD-элементов в настройках.** На всех карточках HUD/UI написано
|
||||
«Module description is missing.». Нужно добавить описания ru/en для каждого `@ModuleInfo`-модуля и
|
||||
каждого HUD-элемента (ключи `module.<id>.description` и HUD-аналог), проверить, что не осталось
|
||||
пустых (тест или скрипт сверки id <-> lang).
|
||||
- [x] **Описание функции при наведении в обычном ClickGUI** (колонки Combat/Player/Visuals/Other).
|
||||
В области сверху по центру над колонками показывать описание функции под курсором (плашка в нашем
|
||||
стиле, плавное появление, перенос/обрезка по ширине).
|
||||
|
||||
Задача Б (форк, главное меню):
|
||||
- [x] **Hero-меню «калово»**: заголовок налезает на подзаголовок и кнопки, нет иконок на кнопках,
|
||||
мелкие и кривые шрифты. Эталон: референс Vectra (иконки слева в кнопках, акцентная Multiplayer,
|
||||
тёмное стекло у остальных, пара Options/Quit внизу, карточка аккаунта слева сверху, виджет
|
||||
времени справа сверху). Раскладку считать по измеренным размерам текста, а не по константам.
|
||||
- [x] **Основное (orbs) меню тоже улучшить стилем** (типографика, тени, hover, появление).
|
||||
- [x] **Настройки главного меню разбросаны и спрятаны** (`menuLayout`, `menuBg`, `menuBgUseTheme`,
|
||||
`menuClockShowSeconds` в `MainConfig`). Сгруппировать в понятный раздел «Главное меню» в
|
||||
настройках (ключи конфигов не менять, чтобы сохранённые значения не потерялись).
|
||||
|
||||
### Правки после второго запуска (2026-10-01)
|
||||
|
||||
- [x] `%tier <player>`: не было `suggest` (подсказка показывала только серый `<player>`): теперь ники игроков и режимы
|
||||
(`vanilla|uhc|pot|nethop|smp|sword|axe|mace`). Остальные команды без `suggest` не имеют аргументов.
|
||||
- [x] KillEffect Blood было видно сквозь блоки: пайплайн `WORLD_COLORED` без depth-теста заменён на `WORLD_COLORED_DEPTH`.
|
||||
- [x] TargetESP: новый режим `swords` (8 мечей на орбите, пружинная физика, отдача при ударе, подсветка при хите).
|
||||
`targetesp/sword/` (`SwordPhysics` чистая + 4 теста, `TargetEspSwords` рендер), текстура `particles/sword.png`
|
||||
нарисована скриптом (пиксель-арт, окрашивается цветом палитры). Мечи рисуются поверх блоков (ESP, без depth).
|
||||
Не проверено вживую: вид, размер, яркость свечения, текстура меча (при желании заменить на свою).
|
||||
|
||||
### Фаза 13: разбор evo клиента (2026-10-01), план работ
|
||||
|
||||
Отчёт: `mod/docs/EVO_REPORT.md` (29 модулей evo, которых нет у нас; крылья; ножи; HMI/Lua).
|
||||
Лицензия: `ref/evo/README.md` объявляет код и ассеты проприетарными. Для релиза только свои модели/скрипты
|
||||
или разрешение автора; формат (Bedrock geo/anim, HMI-Lua) стандартный, код пишем свой.
|
||||
Порядок по просьбе владельца: сначала 1, потом остальное.
|
||||
|
||||
- [x] 1. HoldMyItems Scripted на LuaJ (пункты 1-8 отчёта, частично): настройка `Режим` = Dynamics / Scripts / Both
|
||||
(по умолчанию Dynamics, LuaJ грузится лениво). `held/lua/`: `HandScripts` (загрузка + горячая перезагрузка раз
|
||||
в секунду из `config/lovisual/holdmyitems/{hand_pose,item_pose}.lua`, иначе встроенные примеры в
|
||||
`assets/lovisual/holdmyitems/`), `HandFrame`; `runtime/`: `LuaSandbox` (только base/bit32/table/string/math,
|
||||
без io/os/luajava/require/load/debug, лимит инструкций через хук), `LuaPreprocessor` (`global.x = v;` -> registry),
|
||||
`ScriptSlot` (ошибка выключает только этот скрипт, лог один раз); `api/`: `M` (move/rotate с pivot/scale/push/pop/
|
||||
math), `P`, `I`, `Easings` (8 функций), без отдачи Java-объектов в Lua. Хуки в `ItemInHandRendererMixin`:
|
||||
hand_pose в `submitArmWithItem`, item_pose в `renderItem` (first person). 7 тестов `LuaSandboxTest`.
|
||||
(Хвост из 1-8 закрыт в пункте 1b ниже.)
|
||||
- [x] 1b. HMI хвост (сделано): 4 вида скриптов `hand_pose`, `hand_relative_pose`, `item_pose`, `item_model` + аддоны
|
||||
`hand_addon`, `hand_relative_addon`, `item_addon`, `item_model_addon` (только из `config/lovisual/holdmyitems/`,
|
||||
выполняются сразу после основного). `V:cancelEquip/cancelSwing(bool)` отключают ваниль (флаги читаются на следующем
|
||||
кадре); настройка «Всегда рисовать обе руки» (`always_hands`); `animator` (`ext/ModelAnimator` + миксин
|
||||
`ItemModelAnimatorMixin` подменяет список квадов слоя, операции rotate/move/scale по диапазону индексов, pivot
|
||||
в пикселях); `particleManager:addParticle{...}` с Lua-колбэком `tick` (`ext/HandParticles`, аддитивный BLOOM в
|
||||
мире); `C:setCamRot/setCamPos` (`CameraScriptMixin`, действует 150 мс после вызова). Встроенные примеры для всех
|
||||
скриптов. Тесты: `QuadOpsTest`. Не сделано: события `swingMHand/interact/blockBreaking`, `use_duration`/
|
||||
`using_item` для моделей, ItemStack-поля (`setRenderAsBlock`), крен камеры (roll), загрузка из ресурс-паков.
|
||||
Не проверено вживую: оси и знаки M:rotate, видимость Lua-частиц, анимация квадов на реальной модели.
|
||||
- [ ] 2. Wings: режим «Модель» из evo (подробности: `docs/EVO_REPORT.md`, раздел 2, шаги в 2.4).
|
||||
Формат Bedrock `geo.json` + `animation.json`; 14 видов: angel, blue_dragon, dark, demon, dragon, easter, ghoul,
|
||||
pulse, rocker, spider, trident, vulcano, wota (+ baby_dragon). angel/demon/dragon анимируются процедурно в коде
|
||||
evo (`Internal29/41/42`), остальные из animation.json; wota с анимированной текстурой (64x640, 10 кадров по 150 мс).
|
||||
Источник (локально): `ref/evo/mod/jar/assets/evolution/cosmetic/wings/*.{geo,animation}.json`,
|
||||
`.../textures/cosmetic/wings/*.png`. Целевые пути: `assets/lovisual/wings/`, `assets/lovisual/textures/wings/`.
|
||||
- [ ] `BedrockGeoParser` (общий с ножами) + запекание кубов в позиции/UV
|
||||
- [ ] `BedrockAnimation` (keyframe, loop) и `ProceduralWingAnims` (angel/demon/dragon)
|
||||
- [ ] `WingsRenderer.renderModel` (WORLD_TEXTURED[_DEPTH], якорь как у `drawWingSide`, подобрать знак Z и ориентацию)
|
||||
- [ ] настройки в `Wings`: `wingSource` (Procedural/Model) + `wingModel` (14 ключей), tint/glow, lang en/ru
|
||||
- [ ] тесты парсера, знаков pivot/rotation, UV, интерполяции
|
||||
- [ ] 3. Модуль Knife (Melee Skins), раздел 3 отчёта, таблица классов в 3.7. 33 скина (21 геометрия, 12 перекрасок),
|
||||
клипы draw/draw_1/inspect/inspect_1/melee_1..3/static_idle/run_start/run/run_end; ЛКМ чередует melee_1/2,
|
||||
ПКМ melee_3, бег при спринте; first person и third person; звуки (94 ogg); шейдер holy.
|
||||
Источник: `ref/evo/mod/jar/assets/evolution/cosmetic/melee/` (index.json, *.geo.json, *.anim.json), `textures/cosmetic/melee/`,
|
||||
`sounds/melee/`, `shaders/core/melee_holy.*`. Целевые: `assets/lovisual/knife/`, `textures/knife/`, `sounds/knife/`.
|
||||
Порядок: (1) общий BedrockGeoParser, (2) клипы + `KnifeAnimationState`, (3) first person без рук, (4) руки игрока,
|
||||
(5) third person (миксин `PlayerItemInHandLayer`), (6) звуки, (7) бег/варианты/holy, (8) UI выбора скина и сохранение.
|
||||
Миксины: ввод (атака/ПКМ), `ItemInHandRenderer`, `PlayerItemInHandLayer`.
|
||||
- [ ] 4. Быстрые модули (P1, размер S): TNTTimer, AutoSprint (есть `SprintControlEvent`), TotemCounter, EnchantGlint,
|
||||
PlaceAnimation, GodRays (референс-шейдеры `shaders/core/godrays`), ProjectileTrails (на нашем `Trails`), ArmorColor.
|
||||
- [ ] 5. Дальше (P1-P2): DeathEffects (распад на части, шейдер `death_blast`), Blizzard, ElytraTrails, TotemSound, Sounds,
|
||||
InventorySort, StorageTracker, ConsumeESP, CustomHand, DynamicLights (проверить `Light`), FoodInfo, PatPat, Showcase.
|
||||
P3: Chunk Cache, Schematics, HitFix (риск античита), Camera, Graffiti, Skin Wheel, Discord RPC, Replay, Trainer.
|
||||
Пропустить: GuiPresence (нужен бэкенд evo). Полный список из 29 модулей с оценкой: отчёт, раздел 1.2.
|
||||
- [ ] 6. Лицензия: `ref/evo/README.md` объявляет код и ассеты evo проприетарными. Перед релизом заменить на свои модели/
|
||||
звуки или получить разрешение автора; до тех пор пути из отчёта только для локальной отладки, в репозиторий
|
||||
ассеты evo не коммитить.
|
||||
|
||||
### Не проверено вживую / открытые вопросы
|
||||
|
||||
- [ ] Проверить в игре автокомплит `%`: ввод `%`, `%to`, Tab; серая подсказка `<module>` после `%toggle `.
|
||||
Если миксин не применится, прислать лог (поля `CommandSuggestions` сверены по байткоду 26.2).
|
||||
- [ ] Визуально подогнать: режим «Созвездие», новые формы JumpCircles (ориентация текстур, яркость
|
||||
аддитивного бленда), кровь (лужи, яркость), Hero/orbs-меню, индикаторы войсчата.
|
||||
- [ ] Войсчат: события SVC трактованы по javap (`MicrophoneMuteEvent.isDisabled()` как «заглушён или
|
||||
выключен», `getEntityId()` как UUID игрока); проверить с настоящим SVC 2.6.24; значок шапки HUD-панели
|
||||
сейчас wifi-глиф (подходящего микрофона в шрифте нет).
|
||||
- [ ] Тип ClickGUI (идея владельца): селектор «Тип ClickGUI» в настройках + точка выбора экрана
|
||||
(`ClickGuiScreenManager`, три места `new ClickGuiScreen()`), `classic` по умолчанию, второй вид позже;
|
||||
бинд и звуки остаются в модуле `ClickGui`. Ждёт решения, какой второй вид делать первым.
|
||||
- [ ] `.gitignore` для `server-plugin/.classpath`, `.project`, `.settings/`.
|
||||
- [ ] Фаза 8.5.3 по-прежнему в очереди (около 150 файлов >250 строк), по мере касания.
|
||||
- [ ] Приёмка серверного плагина (Фаза 11 в корневом TODO.md): Velocity + `%policy` на стенде.
|
||||
|
||||
### Заметки для следующих сессий
|
||||
|
||||
- Игру запускает владелец: `cd mod && ./gradlew runClient`. Из корня репозитория `./gradlew` не существует.
|
||||
- Параллельные gradle-сборки блокируют друг друга: форкам запускать через
|
||||
`flock /tmp/claude-1000/gradle.lock ./gradlew build --offline -q`.
|
||||
- Новые классы-помощники для миксинов НЕЛЬЗЯ класть в пакет `dev.loki.lovisual.mixins.*`
|
||||
(Mixin: `IllegalClassLoadError`); им место в `mixinhandlers/` или `render/`.
|
||||
- Лимиты проекта: ≤250 строк на файл, ≤4 файла на папку (`checkFolderLimit` падает при нарушении),
|
||||
без длинного тире в текстах сайта и ответах.
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
package dev.loki.lovisual.features.gui.clickgui.layout.screen.modules;
|
||||
|
||||
import dev.loki.lovisual.features.gui.clickgui.layout.screen.modules.model.MenuPresence;
|
||||
import dev.loki.lovisual.features.gui.clickgui.tabs.ClickGuiSearch;
|
||||
|
||||
/** Builds the {@link MenuPresence} snapshot of the modules menu for the given mouse position. */
|
||||
final class ModulesMenuPresence {
|
||||
|
||||
private static final int NO_MODULE = 255;
|
||||
|
||||
private ModulesMenuPresence() {
|
||||
}
|
||||
|
||||
static MenuPresence snapshot(ModulesMenuScreen screen, float mx, float my, boolean pressed) {
|
||||
float minX = Float.MAX_VALUE, minY = Float.MAX_VALUE, maxX = -Float.MAX_VALUE, maxY = -Float.MAX_VALUE;
|
||||
for (ModulesMenuPanel p : screen.panels) {
|
||||
minX = Math.min(minX, p.x);
|
||||
minY = Math.min(minY, p.y);
|
||||
maxX = Math.max(maxX, p.x + p.w);
|
||||
maxY = Math.max(maxY, p.y + p.h);
|
||||
}
|
||||
if (maxX <= minX || maxY <= minY) return null;
|
||||
int category = -1;
|
||||
int module = NO_MODULE;
|
||||
boolean settings = false;
|
||||
int scroll = 0;
|
||||
for (ModulesMenuPanel p : screen.panels) {
|
||||
if (!ModulesMenuScreen.inside(mx, my, p.x, p.y, p.w, p.h)) continue;
|
||||
category = p.index;
|
||||
settings = p.selected != null;
|
||||
if (settings) {
|
||||
scroll = ratio(p.settingsSmoothScroll, p.maxSettingsScroll);
|
||||
break;
|
||||
}
|
||||
scroll = ratio(p.modulesSmoothScroll, p.maxModulesScroll);
|
||||
for (int i = 0; i < p.hits.size() && i < NO_MODULE; i++) {
|
||||
ModulesMenuPanel.ModuleHit h = p.hits.get(i);
|
||||
if (ModulesMenuScreen.inside(mx, my, h.x(), h.y(), h.w(), h.h())) {
|
||||
module = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
float x = Math.max(0.0f, Math.min(1.0f, (mx - minX) / (maxX - minX)));
|
||||
float y = Math.max(0.0f, Math.min(1.0f, (my - minY) / (maxY - minY)));
|
||||
return new MenuPresence(category, module, settings, ClickGuiSearch.isActive(), x, y, pressed, scroll);
|
||||
}
|
||||
|
||||
private static int ratio(float value, float max) {
|
||||
return max <= 0.0f ? 0 : Math.max(0, Math.min(255, Math.round(Math.abs(value) / max * 255.0f)));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package dev.loki.lovisual.features.gui.clickgui.layout.screen.modules;
|
||||
|
||||
import dev.loki.lovisual.config.i18n.MainConfig;
|
||||
import dev.loki.lovisual.features.gui.clickgui.layout.screen.modules.model.MenuPresence;
|
||||
import dev.loki.lovisual.features.gui.clickgui.layout.screen.modules.model.ModulesMenuCategory;
|
||||
import dev.loki.lovisual.features.gui.clickgui.layout.screen.modules.model.ModulesMenuStyle;
|
||||
import dev.loki.lovisual.features.gui.clickgui.render.ClickGuiRenderer;
|
||||
|
|
@ -32,6 +33,8 @@ public final class ModulesMenuScreen {
|
|||
float searchAnim = 0.0f; float searchX; float searchY; float searchW; float searchH;
|
||||
float searchBtnX; float searchBtnY; float searchBtnW; float searchBtnH; float searchBtnHoverAnim;
|
||||
float descAnim; String descTitle; String descText;
|
||||
private static ModulesMenuScreen active;
|
||||
private float lastMouseX; private float lastMouseY;
|
||||
|
||||
public ModulesMenuScreen() {
|
||||
for (ModulesMenuCategory c : ModulesMenuCategory.values()) panels.add(new ModulesMenuPanel(c, c.ordinal()));
|
||||
|
|
@ -48,12 +51,20 @@ public final class ModulesMenuScreen {
|
|||
}
|
||||
public boolean isVisible() { return openTarget || screenAnim > 0.001f; }
|
||||
|
||||
/** State of the currently shown modules menu for GUI presence, or null when no menu is open. */
|
||||
public static MenuPresence presenceSnapshot(boolean mouseDown) {
|
||||
ModulesMenuScreen s = active;
|
||||
if (s == null || !s.openTarget || s.screenAnim < 0.6f) return null;
|
||||
return ModulesMenuPresence.snapshot(s, s.lastMouseX, s.lastMouseY, mouseDown);
|
||||
}
|
||||
|
||||
public void layout(float x, float y, float w, float h) {
|
||||
this.areaX = x; this.areaY = y; this.areaW = Math.max(1.0f, w); this.areaH = Math.max(1.0f, h);
|
||||
ModulesMenuLayout.layout(this, panels, areaX, areaY, areaW, areaH);
|
||||
}
|
||||
|
||||
public void render(float mouseX, float mouseY) {
|
||||
lastMouseX = mouseX; lastMouseY = mouseY; active = this;
|
||||
ModulesMenuChromeRenderer.ensureFonts();
|
||||
ModulesMenuStyle.syncTheme();
|
||||
float dt = AnimationUtility.deltaTime();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
package dev.loki.lovisual.features.gui.clickgui.layout.screen.modules.model;
|
||||
|
||||
/**
|
||||
* Snapshot of the local ClickGUI for GUI presence: hovered column, module row under the cursor (255 = none),
|
||||
* whether that column shows a module's settings, search state, cursor position normalised to the panels' bounds
|
||||
* (0..1), left button and list scroll (0..255).
|
||||
*/
|
||||
public record MenuPresence(int category, int moduleIndex, boolean settingsOpen, boolean searching,
|
||||
float x, float y, boolean pressed, int scroll) {
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
package dev.loki.lovisual.features.module.modules.visuals.presence;
|
||||
|
||||
import dev.loki.lovisual.config.values.primitive.BooleanValue;
|
||||
import dev.loki.lovisual.config.values.primitive.NumberValue;
|
||||
import dev.loki.lovisual.features.module.core.Module;
|
||||
import dev.loki.lovisual.features.module.core.ModuleCategory;
|
||||
import dev.loki.lovisual.features.module.core.ModuleInfo;
|
||||
import dev.loki.lovisual.features.module.phase.WorldPhase;
|
||||
import dev.loki.lovisual.features.platform.presence.PresenceClient;
|
||||
import dev.loki.lovisual.features.platform.presence.PresenceTimeline;
|
||||
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import net.minecraft.client.multiplayer.ServerData;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Shows other LoVisual users' open ClickGUI as a small mirror held in front of them, and shares your own cursor the
|
||||
* same way. Needs a linked LoVisual account (device token) and a multiplayer server; data goes through the
|
||||
* chat-service presence endpoint and contains only the cursor position and a few menu flags.
|
||||
*/
|
||||
@ModuleInfo(
|
||||
id = "guipresence",
|
||||
displayName = "GuiPresence",
|
||||
category = ModuleCategory.VISUALS,
|
||||
description = "Shows the ClickGUI other LoVisual users have open, and shares yours"
|
||||
)
|
||||
public class GuiPresence extends Module {
|
||||
|
||||
private static final float FADE_RANGE = 6.0f;
|
||||
private static final int MAX_WANT = 40;
|
||||
|
||||
private final PresenceClient client = new PresenceClient();
|
||||
private final PresenceSampler sampler = new PresenceSampler();
|
||||
private final PresencePanelRenderer panel = new PresencePanelRenderer();
|
||||
private final BooleanValue share = bool("guiPresenceShare", "share", true);
|
||||
private final BooleanValue showOthers = bool("guiPresenceShowOthers", "show_others", true);
|
||||
private final NumberValue<Integer> distance = visibleWhen(num("guiPresenceDistance", "distance", 32, 8, 64), showOthers::get);
|
||||
private final NumberValue<Float> scale = visibleWhen(num("guiPresenceScale", "scale", 1.0f, 0.5f, 2.0f), showOthers::get);
|
||||
|
||||
@Override
|
||||
public WorldPhase getWorldPhase() {
|
||||
return WorldPhase.END_MAIN;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
client.clear();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTick() {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
ServerData server = mc.getCurrentServer();
|
||||
if (!isEnabled() || mc.level == null || mc.player == null || server == null) return;
|
||||
if (!share.get() && !showOthers.get()) return;
|
||||
long now = System.currentTimeMillis();
|
||||
boolean open = share.get() && sampler.sample(now);
|
||||
if (!client.due(now, open)) return;
|
||||
client.sync(now, server.ip, mc.player.getUUID(), open ? sampler.drain() : null, share.get(), wanted(mc));
|
||||
}
|
||||
|
||||
private List<UUID> wanted(Minecraft mc) {
|
||||
List<UUID> want = new ArrayList<>();
|
||||
if (!showOthers.get()) return want;
|
||||
double max = distance.get() + FADE_RANGE;
|
||||
for (Player p : mc.level.players()) {
|
||||
if (p == mc.player || mc.player.distanceTo(p) > max) continue;
|
||||
want.add(p.getUUID());
|
||||
if (want.size() >= MAX_WANT) break;
|
||||
}
|
||||
return want;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRenderWorldEngine(Renderer3D renderer, Renderer3D depthRenderer, float tickDelta) {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
if (!isEnabled() || !showOthers.get() || mc.level == null || mc.player == null) return;
|
||||
long now = System.currentTimeMillis();
|
||||
for (Map.Entry<UUID, PresenceTimeline> e : client.remote().entrySet()) {
|
||||
Player p = mc.level.getPlayerByUUID(e.getKey());
|
||||
if (p == null || p == mc.player || !e.getValue().live(now)) continue;
|
||||
float d = mc.player.distanceTo(p);
|
||||
float fade = Math.min(1.0f, (distance.get() - d) / FADE_RANGE + 1.0f);
|
||||
panel.draw(renderer, p, tickDelta, e.getValue(), now, scale.get(), Math.max(0.0f, fade));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
package dev.loki.lovisual.features.module.modules.visuals.presence;
|
||||
|
||||
import dev.loki.lovisual.features.platform.presence.PresenceCodec;
|
||||
import dev.loki.lovisual.features.platform.presence.PresenceTimeline;
|
||||
import dev.loki.lovisual.features.theme.Theme;
|
||||
import dev.loki.lovisual.render.engine.color.RenderColor;
|
||||
import dev.loki.lovisual.render.engine.pipeline.LoVisualRenderPipelines;
|
||||
import dev.loki.lovisual.render.engine.renderer.Renderer3D;
|
||||
import dev.loki.lovisual.render.engine.uniform.MeshBuilder;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
/**
|
||||
* Draws a remote player's ClickGUI as a small flat mirror held in front of them: four columns, the one under their
|
||||
* cursor lit, the row under the cursor highlighted, the cursor itself and a ring while the button is pressed.
|
||||
* Built from coloured quads in a plane spanned by the player's facing, so it needs no text or textures.
|
||||
*/
|
||||
final class PresencePanelRenderer {
|
||||
|
||||
private static final float ASPECT = 65.0f / 104.0f;
|
||||
private static final float FORWARD = 0.85f;
|
||||
private static final float HEIGHT_RATIO = 0.6f;
|
||||
private static final int COLUMNS = 4;
|
||||
private static final int ROWS = 7;
|
||||
private static final float GAP = 0.018f;
|
||||
private static final float LAYER = 0.0025f;
|
||||
|
||||
private final float[] a = new float[3];
|
||||
private final float[] up = {0.0f, 1.0f, 0.0f};
|
||||
private final float[] f = new float[3];
|
||||
private final float[] c = new float[3];
|
||||
private float width;
|
||||
private float height;
|
||||
|
||||
void draw(Renderer3D renderer, Player player, float tickDelta, PresenceTimeline timeline, long now,
|
||||
float scale, float alpha) {
|
||||
PresenceCodec.Gui gui = timeline.meta();
|
||||
PresenceTimeline.Point cursor = timeline.at(now);
|
||||
if (gui == null || gui.closed() || cursor == null || alpha <= 0.01f) return;
|
||||
MeshBuilder m = renderer.batch(LoVisualRenderPipelines.WORLD_COLORED_DEPTH, Renderer3D.DepthMode.MAIN);
|
||||
if (m == null) return;
|
||||
place(player, tickDelta, scale);
|
||||
int accent = Theme.theme().accent() & 0x00FFFFFF;
|
||||
int bg = Theme.theme().windowBg() & 0x00FFFFFF;
|
||||
quad(m, 0.0f, 0.0f, 1.0f, 1.0f, 0, rgba(bg, 0.82f * alpha));
|
||||
float colW = (1.0f - GAP * (COLUMNS + 1)) / COLUMNS;
|
||||
float cx = cursor.x() / 255.0f;
|
||||
float cy = cursor.y() / 255.0f;
|
||||
for (int i = 0; i < COLUMNS; i++) {
|
||||
float x0 = GAP + i * (colW + GAP);
|
||||
boolean active = i == gui.category();
|
||||
quad(m, x0, 0.04f, x0 + colW, 0.96f, 1, rgba(active ? accent : bg, (active ? 0.28f : 0.5f) * alpha));
|
||||
quad(m, x0, 0.04f, x0 + colW, 0.13f, 2, rgba(accent, (active ? 0.7f : 0.3f) * alpha));
|
||||
rows(m, i, x0, colW, active, gui, cy, accent, alpha);
|
||||
}
|
||||
if (gui.searching()) quad(m, 0.3f, -0.1f, 0.7f, -0.03f, 2, rgba(accent, 0.85f * alpha));
|
||||
cursor(m, cx, cy, cursor.pressed(), accent, alpha);
|
||||
}
|
||||
|
||||
private void rows(MeshBuilder m, int column, float x0, float colW, boolean active, PresenceCodec.Gui gui,
|
||||
float cy, int accent, float alpha) {
|
||||
float top = 0.17f;
|
||||
float step = (0.95f - top) / ROWS;
|
||||
int hot = active && !gui.settings() ? (int) Math.floor((cy - top) / step) : -1;
|
||||
for (int r = 0; r < ROWS; r++) {
|
||||
float y0 = top + r * step;
|
||||
float w = active && gui.settings() ? colW * (0.35f + 0.55f * (((r * 37 + column * 11) % 10) / 10.0f)) : colW * 0.82f;
|
||||
boolean lit = r == hot && gui.moduleIndex() != PresenceCodec.NO_MODULE;
|
||||
quad(m, x0 + colW * 0.09f, y0, x0 + colW * 0.09f + w, y0 + step * 0.7f, 2,
|
||||
rgba(lit ? accent : 0xFFFFFF, (lit ? 0.75f : 0.1f) * alpha));
|
||||
}
|
||||
}
|
||||
|
||||
private void cursor(MeshBuilder m, float cx, float cy, boolean pressed, int accent, float alpha) {
|
||||
float s = 0.014f;
|
||||
quad(m, cx - s, cy - s, cx + s, cy + s, 4, rgba(0xFFFFFF, alpha));
|
||||
if (!pressed) return;
|
||||
float r = 0.035f;
|
||||
float t = 0.006f;
|
||||
int col = rgba(accent, 0.9f * alpha);
|
||||
quad(m, cx - r, cy - r, cx + r, cy - r + t, 3, col);
|
||||
quad(m, cx - r, cy + r - t, cx + r, cy + r, 3, col);
|
||||
quad(m, cx - r, cy - r, cx - r + t, cy + r, 3, col);
|
||||
quad(m, cx + r - t, cy - r, cx + r, cy + r, 3, col);
|
||||
}
|
||||
|
||||
private void place(Player p, float tickDelta, float scale) {
|
||||
double yaw = Math.toRadians(p.yBodyRot);
|
||||
a[0] = (float) Math.cos(yaw);
|
||||
a[1] = 0.0f;
|
||||
a[2] = (float) Math.sin(yaw);
|
||||
f[0] = (float) -Math.sin(yaw);
|
||||
f[1] = 0.0f;
|
||||
f[2] = (float) Math.cos(yaw);
|
||||
Vec3 pos = p.getPosition(tickDelta);
|
||||
float h = p.getBbHeight() * HEIGHT_RATIO;
|
||||
c[0] = (float) pos.x + f[0] * FORWARD;
|
||||
c[1] = (float) pos.y + h;
|
||||
c[2] = (float) pos.z + f[2] * FORWARD;
|
||||
width = scale;
|
||||
height = scale * ASPECT;
|
||||
}
|
||||
|
||||
/** Quad in panel space: u to the right, v downward, both 0..1; {@code layer} lifts it toward the viewer. */
|
||||
private void quad(MeshBuilder m, float u0, float v0, float u1, float v1, int layer, int argb) {
|
||||
m.ensureQuadCapacity();
|
||||
RenderColor col = new RenderColor(argb);
|
||||
int i1 = vertex(m, u0, v0, layer, col);
|
||||
int i2 = vertex(m, u1, v0, layer, col);
|
||||
int i3 = vertex(m, u1, v1, layer, col);
|
||||
int i4 = vertex(m, u0, v1, layer, col);
|
||||
m.quad(i1, i2, i3, i4);
|
||||
}
|
||||
|
||||
private int vertex(MeshBuilder m, float u, float v, int layer, RenderColor col) {
|
||||
float du = (u - 0.5f) * width;
|
||||
float dv = (0.5f - v) * height;
|
||||
float off = layer * LAYER;
|
||||
return m.vec3(c[0] + a[0] * du + up[0] * dv + f[0] * off,
|
||||
c[1] + a[1] * du + up[1] * dv + f[1] * off,
|
||||
c[2] + a[2] * du + up[2] * dv + f[2] * off).color(col).next();
|
||||
}
|
||||
|
||||
private static int rgba(int rgb, float alpha) {
|
||||
int al = Math.max(0, Math.min(255, Math.round(alpha * 255.0f)));
|
||||
return (al << 24) | (rgb & 0x00FFFFFF);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
package dev.loki.lovisual.features.module.modules.visuals.presence;
|
||||
|
||||
import dev.loki.lovisual.features.gui.clickgui.layout.screen.modules.ModulesMenuScreen;
|
||||
import dev.loki.lovisual.features.gui.clickgui.layout.screen.modules.model.MenuPresence;
|
||||
import dev.loki.lovisual.features.platform.presence.PresenceCodec;
|
||||
import dev.loki.lovisual.features.platform.presence.PresenceTimeline;
|
||||
import net.minecraft.client.Minecraft;
|
||||
import org.lwjgl.glfw.GLFW;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Samples the local ClickGUI cursor every 100 ms into a short queue that is flushed with the next sync. */
|
||||
final class PresenceSampler {
|
||||
|
||||
private final List<PresenceCodec.Sample> queue = new ArrayList<>();
|
||||
private MenuPresence last;
|
||||
private long lastSample;
|
||||
|
||||
/** @return true while a menu is open (and sampled) */
|
||||
boolean sample(long now) {
|
||||
Minecraft mc = Minecraft.getInstance();
|
||||
boolean down = mc.getWindow() != null
|
||||
&& GLFW.glfwGetMouseButton(mc.getWindow().handle(), GLFW.GLFW_MOUSE_BUTTON_LEFT) == GLFW.GLFW_PRESS;
|
||||
MenuPresence snap = ModulesMenuScreen.presenceSnapshot(down);
|
||||
if (snap == null) {
|
||||
last = null;
|
||||
queue.clear();
|
||||
return false;
|
||||
}
|
||||
last = snap;
|
||||
if (now - lastSample >= PresenceTimeline.SAMPLE_MS) {
|
||||
lastSample = now;
|
||||
queue.add(new PresenceCodec.Sample(Math.round(snap.x() * 255.0f), Math.round(snap.y() * 255.0f),
|
||||
snap.pressed(), snap.scroll()));
|
||||
while (queue.size() > PresenceCodec.MAX_SAMPLES) queue.remove(0);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Payload for the current menu state with the queued samples, or null when no menu is open. */
|
||||
PresenceCodec.Gui drain() {
|
||||
if (last == null) return null;
|
||||
PresenceCodec.Gui gui = new PresenceCodec.Gui(last.category(), last.settingsOpen(), last.searching(), false,
|
||||
last.moduleIndex(), new ArrayList<>(queue));
|
||||
queue.clear();
|
||||
return gui;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
package dev.loki.lovisual.features.platform.presence;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import dev.loki.lovisual.features.platform.PlatformSession;
|
||||
import dev.loki.lovisual.features.platform.http.ApiResult;
|
||||
import dev.loki.lovisual.features.platform.http.ClientThread;
|
||||
import dev.loki.lovisual.features.platform.http.PlatformHttpClient;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Poll loop of GUI presence ({@code POST /presence/gui}): one request publishes the local menu state and returns the
|
||||
* state of nearby players. Must be driven from the client thread; responses hop back onto it, so {@link #remote()}
|
||||
* is only touched there. Adaptive cadence: fast while a menu (local or remote) is live, slow heartbeat otherwise.
|
||||
*/
|
||||
public final class PresenceClient {
|
||||
|
||||
private static final long FAST_MS = 350L;
|
||||
private static final long IDLE_MS = 2000L;
|
||||
private static final long ERROR_BACKOFF_MS = 8000L;
|
||||
private static final long AUTH_BACKOFF_MS = 30000L;
|
||||
private static final long FORGET_MS = 15000L;
|
||||
|
||||
private final Map<UUID, PresenceTimeline> remote = new HashMap<>();
|
||||
private boolean inFlight;
|
||||
private boolean anyLive;
|
||||
private long nextAt;
|
||||
|
||||
public Map<UUID, PresenceTimeline> remote() {
|
||||
return remote;
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
remote.clear();
|
||||
anyLive = false;
|
||||
nextAt = 0L;
|
||||
}
|
||||
|
||||
public boolean due(long now, boolean localOpen) {
|
||||
return !inFlight && now >= nextAt && PlatformSession.get().isLinked();
|
||||
}
|
||||
|
||||
/** Next send interval for the current state; exposed so the caller can decide how often to sample. */
|
||||
public long interval(boolean localOpen) {
|
||||
return localOpen || anyLive ? FAST_MS : IDLE_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param local local menu state to publish, or null for a "LoVisual is running" heartbeat (ignored when
|
||||
* {@code publish} is false)
|
||||
* @param publish false for a read-only poll
|
||||
*/
|
||||
public void sync(long now, String server, UUID self, PresenceCodec.Gui local, boolean publish, List<UUID> want) {
|
||||
inFlight = true;
|
||||
JsonObject body = new JsonObject();
|
||||
body.addProperty("server", server);
|
||||
body.addProperty("mc", self.toString());
|
||||
body.addProperty("publish", publish);
|
||||
if (publish && local != null) body.addProperty("gui", PresenceCodec.encode(local));
|
||||
JsonArray wanted = new JsonArray();
|
||||
want.forEach(id -> wanted.add(id.toString()));
|
||||
body.add("want", wanted);
|
||||
boolean localOpen = local != null && !local.closed();
|
||||
PlatformHttpClient.post("/presence/gui", body, PlatformSession.get().getDeviceToken())
|
||||
.thenAccept(result -> ClientThread.run(() -> onResult(result, localOpen)));
|
||||
}
|
||||
|
||||
private void onResult(ApiResult result, boolean localOpen) {
|
||||
inFlight = false;
|
||||
long now = System.currentTimeMillis();
|
||||
if (!result.success()) {
|
||||
nextAt = now + (result.statusCode() == 401 || result.statusCode() == 409 ? AUTH_BACKOFF_MS : ERROR_BACKOFF_MS);
|
||||
return;
|
||||
}
|
||||
JsonObject obj = result.asObject();
|
||||
JsonElement players = obj == null ? null : obj.get("players");
|
||||
if (players != null && players.isJsonArray()) {
|
||||
for (JsonElement e : players.getAsJsonArray()) apply(e, now);
|
||||
}
|
||||
for (Iterator<PresenceTimeline> it = remote.values().iterator(); it.hasNext(); ) {
|
||||
if (!it.next().present(now - FORGET_MS + PresenceTimeline.STALE_MS * 4)) it.remove();
|
||||
}
|
||||
anyLive = remote.values().stream().anyMatch(t -> t.live(now));
|
||||
nextAt = now + interval(localOpen);
|
||||
}
|
||||
|
||||
private void apply(JsonElement e, long now) {
|
||||
if (!e.isJsonObject()) return;
|
||||
JsonObject o = e.getAsJsonObject();
|
||||
try {
|
||||
UUID id = UUID.fromString(o.get("mc").getAsString());
|
||||
long age = o.has("age") ? o.get("age").getAsLong() : 0L;
|
||||
JsonElement g = o.get("gui");
|
||||
PresenceCodec.Gui gui = g == null || g.isJsonNull() ? null : PresenceCodec.decode(g.getAsString());
|
||||
if (gui == null) gui = new PresenceCodec.Gui(-1, false, false, true, PresenceCodec.NO_MODULE, List.of());
|
||||
remote.computeIfAbsent(id, k -> new PresenceTimeline()).add(now - age, gui);
|
||||
} catch (RuntimeException ignored) {
|
||||
// malformed entry from the server: skip it, the rest of the response is still valid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
package dev.loki.lovisual.features.platform.presence;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Wire format of the GUI presence payload (see {@code backend/chat-service/PLAN.md}): 4 header bytes and up to
|
||||
* 8 cursor samples of 4 bytes. Pure and symmetric, so it is unit-tested without Minecraft.
|
||||
*/
|
||||
public final class PresenceCodec {
|
||||
|
||||
public static final int VERSION = 1;
|
||||
public static final int MAX_SAMPLES = 8;
|
||||
public static final int NO_MODULE = 255;
|
||||
private static final int HEADER = 4;
|
||||
|
||||
/** Cursor position inside the menu (0..255 on each axis), left button state and list scroll (0..255). */
|
||||
public record Sample(int x, int y, boolean pressed, int scroll) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param category menu column 0..15 (-1 is stored as 15 by {@link #encode} and decoded back to -1)
|
||||
* @param moduleIndex index of the module row under the cursor, {@link #NO_MODULE} when none
|
||||
*/
|
||||
public record Gui(int category, boolean settings, boolean searching, boolean closed, int moduleIndex,
|
||||
List<Sample> samples) {
|
||||
}
|
||||
|
||||
private PresenceCodec() {
|
||||
}
|
||||
|
||||
public static String encode(Gui gui) {
|
||||
int n = Math.min(MAX_SAMPLES, gui.samples().size());
|
||||
byte[] out = new byte[HEADER + n * 4];
|
||||
out[0] = VERSION;
|
||||
int cat = gui.category() < 0 ? 15 : gui.category() & 15;
|
||||
out[1] = (byte) (cat | (gui.settings() ? 16 : 0) | (gui.searching() ? 32 : 0) | (gui.closed() ? 128 : 0));
|
||||
out[2] = (byte) gui.moduleIndex();
|
||||
out[3] = (byte) n;
|
||||
for (int i = 0; i < n; i++) {
|
||||
Sample s = gui.samples().get(i);
|
||||
out[HEADER + i * 4] = (byte) clamp(s.x());
|
||||
out[HEADER + i * 4 + 1] = (byte) clamp(s.y());
|
||||
out[HEADER + i * 4 + 2] = (byte) (s.pressed() ? 1 : 0);
|
||||
out[HEADER + i * 4 + 3] = (byte) clamp(s.scroll());
|
||||
}
|
||||
return Base64.getEncoder().encodeToString(out);
|
||||
}
|
||||
|
||||
/** Returns null for anything malformed or from another protocol version. */
|
||||
public static Gui decode(String base64) {
|
||||
if (base64 == null) return null;
|
||||
byte[] in;
|
||||
try {
|
||||
in = Base64.getDecoder().decode(base64);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return null;
|
||||
}
|
||||
if (in.length < HEADER || in[0] != VERSION) return null;
|
||||
int n = in[3] & 0xFF;
|
||||
if (n > MAX_SAMPLES || in.length < HEADER + n * 4) return null;
|
||||
int flags = in[1] & 0xFF;
|
||||
int cat = flags & 15;
|
||||
List<Sample> samples = new ArrayList<>(n);
|
||||
for (int i = 0; i < n; i++) {
|
||||
int o = HEADER + i * 4;
|
||||
samples.add(new Sample(in[o] & 0xFF, in[o + 1] & 0xFF, (in[o + 2] & 1) != 0, in[o + 3] & 0xFF));
|
||||
}
|
||||
return new Gui(cat == 15 ? -1 : cat, (flags & 16) != 0, (flags & 32) != 0, (flags & 128) != 0,
|
||||
in[2] & 0xFF, samples);
|
||||
}
|
||||
|
||||
private static int clamp(int v) {
|
||||
return Math.max(0, Math.min(255, v));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
package dev.loki.lovisual.features.platform.presence;
|
||||
|
||||
import java.util.ArrayDeque;
|
||||
|
||||
/**
|
||||
* Playback buffer of one remote player's cursor. Payloads arrive in batches (about 3 per second) carrying samples
|
||||
* spaced {@link #SAMPLE_MS} apart; the renderer plays them back {@link #DELAY_MS} late and interpolates, so the
|
||||
* remote cursor moves smoothly instead of in steps.
|
||||
*/
|
||||
public final class PresenceTimeline {
|
||||
|
||||
public static final long SAMPLE_MS = 100L;
|
||||
public static final long DELAY_MS = 300L;
|
||||
/** A remote menu counts as gone after this long without a fresh payload. */
|
||||
public static final long STALE_MS = 2500L;
|
||||
private static final long MAX_BUFFER_MS = 1500L;
|
||||
/** The same server entry polled twice differs only by clock jitter; payloads closer than this are repeats. */
|
||||
private static final long MIN_GAP_MS = 40L;
|
||||
|
||||
public record Point(float x, float y, boolean pressed, int scroll) {
|
||||
}
|
||||
|
||||
private record Timed(long t, PresenceCodec.Sample sample) {
|
||||
}
|
||||
|
||||
private final ArrayDeque<Timed> buffer = new ArrayDeque<>();
|
||||
private long lastSend = Long.MIN_VALUE;
|
||||
private long lastSeen = Long.MIN_VALUE;
|
||||
private PresenceCodec.Gui meta;
|
||||
|
||||
/**
|
||||
* @param sendMs local-clock time the sender pushed this payload (now minus the server-reported age)
|
||||
* @return false when this payload is a repeat of one already consumed
|
||||
*/
|
||||
public boolean add(long sendMs, PresenceCodec.Gui gui) {
|
||||
if (sendMs < lastSend + MIN_GAP_MS) return false;
|
||||
lastSend = sendMs;
|
||||
lastSeen = sendMs;
|
||||
meta = gui;
|
||||
int n = gui.samples().size();
|
||||
for (int i = 0; i < n; i++) {
|
||||
long t = sendMs - (long) (n - 1 - i) * SAMPLE_MS;
|
||||
if (buffer.isEmpty() || t > buffer.peekLast().t()) buffer.addLast(new Timed(t, gui.samples().get(i)));
|
||||
}
|
||||
while (!buffer.isEmpty() && sendMs - buffer.peekFirst().t() > MAX_BUFFER_MS) buffer.pollFirst();
|
||||
return true;
|
||||
}
|
||||
|
||||
public PresenceCodec.Gui meta() {
|
||||
return meta;
|
||||
}
|
||||
|
||||
/** True while the remote menu is open (a payload not marked closed arrived recently). */
|
||||
public boolean live(long nowMs) {
|
||||
return meta != null && !meta.closed() && nowMs - lastSeen < STALE_MS;
|
||||
}
|
||||
|
||||
/** True when any payload (menu open or not) arrived recently, i.e. the player runs LoVisual. */
|
||||
public boolean present(long nowMs) {
|
||||
return meta != null && nowMs - lastSeen < STALE_MS * 4;
|
||||
}
|
||||
|
||||
/** Interpolated cursor at {@code nowMs - DELAY_MS}, or null with nothing buffered. */
|
||||
public Point at(long nowMs) {
|
||||
if (buffer.isEmpty()) return null;
|
||||
long t = nowMs - DELAY_MS;
|
||||
Timed prev = null;
|
||||
for (Timed next : buffer) {
|
||||
if (next.t() >= t) {
|
||||
if (prev == null) return point(next.sample());
|
||||
float k = (float) (t - prev.t()) / Math.max(1L, next.t() - prev.t());
|
||||
return new Point(lerp(prev.sample().x(), next.sample().x(), k), lerp(prev.sample().y(), next.sample().y(), k),
|
||||
next.sample().pressed(), next.sample().scroll());
|
||||
}
|
||||
prev = next;
|
||||
}
|
||||
return point(prev.sample());
|
||||
}
|
||||
|
||||
private static Point point(PresenceCodec.Sample s) {
|
||||
return new Point(s.x(), s.y(), s.pressed(), s.scroll());
|
||||
}
|
||||
|
||||
private static float lerp(float a, float b, float k) {
|
||||
return a + (b - a) * Math.max(0.0f, Math.min(1.0f, k));
|
||||
}
|
||||
}
|
||||
|
|
@ -3679,5 +3679,10 @@
|
|||
"setting.killeffect.blast_color": "Blast color",
|
||||
"setting.killeffect.blast_color.option.Natural": "Natural",
|
||||
"setting.killeffect.blast_color.option.Client": "Client theme",
|
||||
"setting.killeffect.blast_color.option.Custom": "Custom"
|
||||
"setting.killeffect.blast_color.option.Custom": "Custom",
|
||||
"module.guipresence.description": "Shows the ClickGUI other LoVisual users have open, and shares yours.",
|
||||
"setting.guipresence.share": "Share my menu",
|
||||
"setting.guipresence.show_others": "Show other players' menus",
|
||||
"setting.guipresence.distance": "Distance",
|
||||
"setting.guipresence.scale": "Panel size"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3470,5 +3470,10 @@
|
|||
"setting.killeffect.blast_color": "Цвет взрыва",
|
||||
"setting.killeffect.blast_color.option.Natural": "Природный",
|
||||
"setting.killeffect.blast_color.option.Client": "Цвет темы",
|
||||
"setting.killeffect.blast_color.option.Custom": "Свой"
|
||||
"setting.killeffect.blast_color.option.Custom": "Свой",
|
||||
"module.guipresence.description": "Показывает открытый ClickGUI других пользователей LoVisual и делится вашим.",
|
||||
"setting.guipresence.share": "Показывать моё меню",
|
||||
"setting.guipresence.show_others": "Показывать меню других игроков",
|
||||
"setting.guipresence.distance": "Дистанция",
|
||||
"setting.guipresence.scale": "Размер панели"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,77 @@
|
|||
package dev.loki.lovisual.features.platform.presence;
|
||||
|
||||
import dev.loki.lovisual.features.platform.presence.PresenceCodec.Gui;
|
||||
import dev.loki.lovisual.features.platform.presence.PresenceCodec.Sample;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class PresenceTest {
|
||||
|
||||
private static Gui gui(int n) {
|
||||
List<Sample> samples = new ArrayList<>();
|
||||
for (int i = 0; i < n; i++) samples.add(new Sample(i * 10, 255 - i, i % 2 == 0, i));
|
||||
return new Gui(2, true, false, false, 7, samples);
|
||||
}
|
||||
|
||||
@Test
|
||||
void codecRoundTrips() {
|
||||
Gui in = gui(5);
|
||||
Gui out = PresenceCodec.decode(PresenceCodec.encode(in));
|
||||
assertEquals(in, out);
|
||||
}
|
||||
|
||||
@Test
|
||||
void codecKeepsClosedFlagAndNoCategory() {
|
||||
Gui in = new Gui(-1, false, true, true, PresenceCodec.NO_MODULE, List.of());
|
||||
assertEquals(in, PresenceCodec.decode(PresenceCodec.encode(in)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void encodedPayloadFitsServerLimit() {
|
||||
assertTrue(PresenceCodec.encode(gui(20)).length() <= 48);
|
||||
}
|
||||
|
||||
@Test
|
||||
void decodeRejectsGarbage() {
|
||||
assertNull(PresenceCodec.decode(null));
|
||||
assertNull(PresenceCodec.decode("!!!"));
|
||||
assertNull(PresenceCodec.decode(Base64.getEncoder().encodeToString(new byte[]{9, 0, 0, 0})));
|
||||
assertNull(PresenceCodec.decode(Base64.getEncoder().encodeToString(new byte[]{1, 0, 0, 5})));
|
||||
}
|
||||
|
||||
@Test
|
||||
void timelineInterpolatesBetweenSamples() {
|
||||
PresenceTimeline tl = new PresenceTimeline();
|
||||
Gui g = new Gui(0, false, false, false, 255, List.of(new Sample(0, 0, false, 0), new Sample(100, 200, true, 0)));
|
||||
assertTrue(tl.add(1000, g));
|
||||
PresenceTimeline.Point p = tl.at(1000 + PresenceTimeline.DELAY_MS - 50);
|
||||
assertEquals(50.0f, p.x(), 1e-3f);
|
||||
assertEquals(100.0f, p.y(), 1e-3f);
|
||||
}
|
||||
|
||||
@Test
|
||||
void timelineIgnoresRepeatedPayloadsAndGoesStale() {
|
||||
PresenceTimeline tl = new PresenceTimeline();
|
||||
Gui g = gui(2);
|
||||
assertTrue(tl.add(5000, g));
|
||||
assertFalse(tl.add(5000, g));
|
||||
assertFalse(tl.add(4000, g));
|
||||
assertTrue(tl.live(5000 + 100));
|
||||
assertFalse(tl.live(5000 + PresenceTimeline.STALE_MS + 1));
|
||||
assertTrue(tl.present(5000 + PresenceTimeline.STALE_MS + 1));
|
||||
}
|
||||
|
||||
@Test
|
||||
void closedMenuIsNotLiveButStillPresent() {
|
||||
PresenceTimeline tl = new PresenceTimeline();
|
||||
tl.add(100, new Gui(-1, false, false, true, 255, List.of()));
|
||||
assertFalse(tl.live(200));
|
||||
assertTrue(tl.present(200));
|
||||
assertNull(tl.at(200));
|
||||
}
|
||||
}
|
||||
25
server-plugin/.classpath
Normal file
25
server-plugin/.classpath
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<classpath>
|
||||
<classpathentry kind="src" output="bin/main" path="src/main/java">
|
||||
<attributes>
|
||||
<attribute name="gradle_scope" value="main"/>
|
||||
<attribute name="gradle_used_by_scope" value="main,test"/>
|
||||
</attributes>
|
||||
</classpathentry>
|
||||
<classpathentry kind="src" output="bin/main" path="src/main/resources">
|
||||
<attributes>
|
||||
<attribute name="gradle_scope" value="main"/>
|
||||
<attribute name="gradle_used_by_scope" value="main,test"/>
|
||||
</attributes>
|
||||
</classpathentry>
|
||||
<classpathentry kind="src" output="bin/test" path="src/test/java">
|
||||
<attributes>
|
||||
<attribute name="gradle_scope" value="test"/>
|
||||
<attribute name="gradle_used_by_scope" value="test"/>
|
||||
<attribute name="test" value="true"/>
|
||||
</attributes>
|
||||
</classpathentry>
|
||||
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-25/"/>
|
||||
<classpathentry kind="con" path="org.eclipse.buildship.core.gradleclasspathcontainer"/>
|
||||
<classpathentry kind="output" path="bin/default"/>
|
||||
</classpath>
|
||||
34
server-plugin/.project
Normal file
34
server-plugin/.project
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<projectDescription>
|
||||
<name>lovisual-policy-server-plugin</name>
|
||||
<comment>Project server-plugin created by Buildship.</comment>
|
||||
<projects>
|
||||
</projects>
|
||||
<buildSpec>
|
||||
<buildCommand>
|
||||
<name>org.eclipse.jdt.core.javabuilder</name>
|
||||
<arguments>
|
||||
</arguments>
|
||||
</buildCommand>
|
||||
<buildCommand>
|
||||
<name>org.eclipse.buildship.core.gradleprojectbuilder</name>
|
||||
<arguments>
|
||||
</arguments>
|
||||
</buildCommand>
|
||||
</buildSpec>
|
||||
<natures>
|
||||
<nature>org.eclipse.jdt.core.javanature</nature>
|
||||
<nature>org.eclipse.buildship.core.gradleprojectnature</nature>
|
||||
</natures>
|
||||
<filteredResources>
|
||||
<filter>
|
||||
<id>1790864025951</id>
|
||||
<name></name>
|
||||
<type>30</type>
|
||||
<matcher>
|
||||
<id>org.eclipse.core.resources.regexFilterMatcher</id>
|
||||
<arguments>node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__</arguments>
|
||||
</matcher>
|
||||
</filter>
|
||||
</filteredResources>
|
||||
</projectDescription>
|
||||
13
server-plugin/.settings/org.eclipse.buildship.core.prefs
Normal file
13
server-plugin/.settings/org.eclipse.buildship.core.prefs
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
arguments=--init-script /home/loki/.eclipse/2139989682_linux_gtk_x86_64/configuration/org.eclipse.osgi/169/0/.cp/gradle/init/init.gradle
|
||||
auto.sync=false
|
||||
build.scans.enabled=false
|
||||
connection.gradle.distribution=GRADLE_DISTRIBUTION(LOCAL_INSTALLATION(/usr/share/java/gradle))
|
||||
connection.project.dir=
|
||||
eclipse.preferences.version=1
|
||||
gradle.user.home=
|
||||
java.home=/usr/lib/jvm/java-21-openjdk
|
||||
jvm.arguments=
|
||||
offline.mode=false
|
||||
override.workspace.settings=true
|
||||
show.console.view=true
|
||||
show.executions.view=true
|
||||
4
server-plugin/.settings/org.eclipse.jdt.core.prefs
Normal file
4
server-plugin/.settings/org.eclipse.jdt.core.prefs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
eclipse.preferences.version=1
|
||||
org.eclipse.jdt.core.compiler.codegen.targetPlatform=25
|
||||
org.eclipse.jdt.core.compiler.compliance=25
|
||||
org.eclipse.jdt.core.compiler.source=25
|
||||
Loading…
Add table
Add a link
Reference in a new issue