1use crate::commands::{CommandQueue, ScriptCommand};
6use gizmo_core::World;
7use mlua::prelude::*;
8use std::sync::Arc;
9
10pub fn register_fighter_api(lua: &Lua, command_queue: Arc<CommandQueue>) -> Result<(), LuaError> {
11 crate::api_table::register_protected(lua, "fighter", |fighter_table| {
12
13 fighter_table.raw_set("_buffers", lua.create_table()?)?;
15 fighter_table.raw_set("_is_locked", lua.create_table()?)?;
16
17 {
19 let cq = command_queue.clone();
20 fighter_table.raw_set(
21 "set_move",
22 lua.create_function(
23 move |_, (id, name, startup, active, recovery, damage): (u32, String, u32, u32, u32, f32)| {
24 cq.push(ScriptCommand::SetFighterMove {
25 id,
26 name,
27 startup,
28 active,
29 recovery,
30 damage,
31 });
32 Ok(())
33 },
34 )?,
35 )?;
36 }
37
38 {
40 let cq = command_queue.clone();
41 fighter_table.raw_set(
42 "apply_hitstop",
43 lua.create_function(move |_, (id, frames): (u32, u32)| {
44 cq.push(ScriptCommand::ApplyHitstop(id, frames));
45 Ok(())
46 })?,
47 )?;
48 }
49
50 {
52 let cq = command_queue.clone();
53 fighter_table.raw_set(
54 "apply_hitstun",
55 lua.create_function(move |_, (id, frames): (u32, u32)| {
56 cq.push(ScriptCommand::ApplyHitstun(id, frames));
57 Ok(())
58 })?,
59 )?;
60 }
61
62
63 lua.load(
65 r#"
66 function fighter.is_locked(id)
67 return fighter._is_locked[id] or false
68 end
69
70 function fighter.check_combo(id, combo, max_gap)
71 local buffer = fighter._buffers[id]
72 if not buffer then return false end
73
74 local combo_idx = #combo
75 if combo_idx == 0 then return false end
76
77 local gap_counter = 0
78
79 for i = 1, #buffer do
80 local frame = buffer[i]
81 local target_input = combo[combo_idx]
82
83 if frame.just_pressed[target_input] then
84 combo_idx = combo_idx - 1
85 gap_counter = 0
86 if combo_idx == 0 then
87 return true
88 end
89 elseif gap_counter >= max_gap then
90 return false
91 else
92 gap_counter = gap_counter + 1
93 end
94 end
95
96 return false
97 end
98 "#,
99 )
100 .exec()?;
101
102 Ok(())
103 })
104}
105
106#[tracing::instrument(skip_all, name = "script_fighter_read")]
107pub fn update_fighter_read_api(lua: &Lua, world: &World) -> Result<(), LuaError> {
108 let fighter_table = crate::api_table::raw(lua, "fighter")?;
111
112 let buffers = lua.create_table()?;
113 let is_locked = lua.create_table()?;
114
115 let controllers = world.borrow::<gizmo_physics_core::components::FighterController>();
116 for (eid, _) in controllers.iter() {
117 if let Some(fighter) = controllers.get(eid) {
118 is_locked.set(eid, fighter.is_locked())?;
119
120 let frames_table = lua.create_table()?;
121 for (i, frame) in fighter.input_buffer.frames.iter().enumerate() {
122 let frame_table = lua.create_table()?;
123
124 let jp_table = lua.create_table()?;
125 for k in &frame.just_pressed {
126 jp_table.set(k.clone(), true)?;
127 }
128
129 let p_table = lua.create_table()?;
130 for k in &frame.pressed {
131 p_table.set(k.clone(), true)?;
132 }
133
134 frame_table.set("just_pressed", jp_table)?;
135 frame_table.set("pressed", p_table)?;
136
137 frames_table.set(i + 1, frame_table)?;
138 }
139 buffers.set(eid, frames_table)?;
140 }
141 }
142
143 fighter_table.raw_set("_buffers", buffers)?;
144 fighter_table.raw_set("_is_locked", is_locked)?;
145
146 Ok(())
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152 use crate::commands::CommandQueue;
153
154 fn run_combo(setup: &str) -> bool {
157 let lua = Lua::new();
158 register_fighter_api(&lua, Arc::new(CommandQueue::new())).unwrap();
159 lua.load(setup).exec().unwrap();
160 lua.load("return fighter.check_combo(1, combo, max_gap)")
161 .eval()
162 .unwrap()
163 }
164
165 #[test]
169 fn combo_gap_boundary_is_exact() {
170 let accepted = run_combo(
173 r#"
174 combo = { "a", "b" }
175 max_gap = 2
176 local function f(k) return { just_pressed = k and { [k] = true } or {} } end
177 fighter._buffers[1] = { f("b"), f(nil), f(nil), f("a") }
178 "#,
179 );
180 assert!(accepted, "2 frame boşluk max_gap=2 için kabul edilmeli");
181
182 let rejected = run_combo(
184 r#"
185 combo = { "a", "b" }
186 max_gap = 2
187 local function f(k) return { just_pressed = k and { [k] = true } or {} } end
188 fighter._buffers[1] = { f("b"), f(nil), f(nil), f(nil), f("a") }
189 "#,
190 );
191 assert!(
192 !rejected,
193 "3 frame boşluk max_gap=2 için REDDEDİLMELİ (off-by-one)"
194 );
195 }
196
197 #[test]
199 fn adjacent_full_combo_is_recognized() {
200 let accepted = run_combo(
201 r#"
202 combo = { "a", "b", "c" }
203 max_gap = 1
204 local function f(k) return { just_pressed = { [k] = true } } end
205 -- Buffer ileri taranır, önce combo'nun sonu ("c") aranır: c, b, a
206 fighter._buffers[1] = { f("c"), f("b"), f("a") }
207 "#,
208 );
209 assert!(accepted, "bitişik c-b-a dizisi a,b,c kombosunu tamamlamalı");
210 }
211
212 #[test]
214 fn empty_combo_never_matches() {
215 let matched = run_combo(
216 r#"
217 combo = {}
218 max_gap = 5
219 local function f(k) return { just_pressed = { [k] = true } } end
220 fighter._buffers[1] = { f("a"), f("b") }
221 "#,
222 );
223 assert!(!matched, "boş kombo false dönmeli");
224 }
225
226 #[test]
228 fn missing_buffer_returns_false() {
229 let matched = run_combo(
230 r#"
231 combo = { "a" }
232 max_gap = 5
233 -- fighter._buffers[1] hiç ayarlanmadı
234 "#,
235 );
236 assert!(!matched, "buffer yoksa false dönmeli");
237 }
238
239 #[test]
241 fn partially_matched_combo_is_rejected() {
242 let matched = run_combo(
243 r#"
244 combo = { "a", "b" }
245 max_gap = 5
246 local function f(k) return { just_pressed = { [k] = true } } end
247 -- Sadece "b" var, "a" hiç basılmadı → kombo tamamlanmaz.
248 fighter._buffers[1] = { f("b"), f("x"), f("y") }
249 "#,
250 );
251 assert!(!matched, "eksik kombo tamamlanmamış sayılmalı");
252 }
253
254 #[test]
256 fn is_locked_reads_table_with_false_default() {
257 let lua = Lua::new();
258 register_fighter_api(&lua, Arc::new(CommandQueue::new())).unwrap();
259 lua.load(
260 r#"
261 assert(fighter.is_locked(1) == false, "giriş yoksa varsayılan false")
262 fighter._is_locked[1] = true
263 assert(fighter.is_locked(1) == true, "true set edilince true")
264 "#,
265 )
266 .exec()
267 .unwrap();
268 }
269
270 #[test]
272 fn fighter_write_calls_push_expected_commands() {
273 let lua = Lua::new();
274 let cq = Arc::new(CommandQueue::new());
275 register_fighter_api(&lua, cq.clone()).unwrap();
276
277 lua.load(
278 r#"
279 fighter.set_move(1, "jab", 3, 2, 8, 5.5)
280 fighter.apply_hitstop(1, 6)
281 fighter.apply_hitstun(2, 20)
282 "#,
283 )
284 .exec()
285 .unwrap();
286
287 let cmds = cq.drain();
288 assert_eq!(cmds.len(), 3);
289 match &cmds[0] {
290 ScriptCommand::SetFighterMove { id, name, startup, active, recovery, damage } => {
291 assert_eq!(*id, 1);
292 assert_eq!(name, "jab");
293 assert_eq!((*startup, *active, *recovery), (3, 2, 8));
294 assert!((damage - 5.5).abs() < 1e-6);
295 }
296 other => panic!("beklenen SetFighterMove, gelen {other:?}"),
297 }
298 assert!(matches!(cmds[1], ScriptCommand::ApplyHitstop(1, 6)));
299 assert!(matches!(cmds[2], ScriptCommand::ApplyHitstun(2, 20)));
300 }
301}