Skip to content

Commit 96f292a

Browse files
committed
Add hand-by-hand score playback
1 parent d3b7a06 commit 96f292a

5 files changed

Lines changed: 171 additions & 21 deletions

File tree

lib/match_history.lua

Lines changed: 102 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,37 +65,134 @@ function MP.MATCH_RECORD.finalize(won)
6565
MP.MATCH_RECORD.final_ante = G.GAME.round_resets and G.GAME.round_resets.ante or 1
6666
end
6767

68-
-- Ghost Replay playback state
69-
-- Loads a stored match record and provides enemy scores for PvP blinds
70-
-- so practice mode can simulate playing against a past opponent.
68+
MP.GHOST = { active = false, replay = nil, flipped = false, gamemode = nil }
7169

72-
MP.GHOST = { active = false, replay = nil, flipped = false }
70+
-- Per-ante playback state
71+
MP.GHOST._hands = {}
72+
MP.GHOST._hand_idx = 0
73+
MP.GHOST._advancing = false
7374

7475
function MP.GHOST.load(replay)
7576
MP.GHOST.active = true
7677
MP.GHOST.replay = replay
7778
MP.GHOST.flipped = false
79+
MP.GHOST.gamemode = replay and replay.gamemode or nil
80+
MP.GHOST._hands = {}
81+
MP.GHOST._hand_idx = 0
82+
MP.GHOST._advancing = false
7883
end
7984

8085
function MP.GHOST.clear()
8186
MP.GHOST.active = false
8287
MP.GHOST.replay = nil
8388
MP.GHOST.flipped = false
89+
MP.GHOST.gamemode = nil
90+
MP.GHOST._hands = {}
91+
MP.GHOST._hand_idx = 0
92+
MP.GHOST._advancing = false
8493
end
8594

8695
function MP.GHOST.flip()
8796
MP.GHOST.flipped = not MP.GHOST.flipped
8897
end
8998

99+
function MP.GHOST.get_enemy_hands(ante)
100+
if not MP.GHOST.replay or not MP.GHOST.replay.ante_snapshots then return {} end
101+
local snapshot = MP.GHOST.replay.ante_snapshots[ante] or MP.GHOST.replay.ante_snapshots[tostring(ante)]
102+
if not snapshot or not snapshot.hands then return {} end
103+
local enemy_side = MP.GHOST.flipped and "player" or "enemy"
104+
local out = {}
105+
for _, h in ipairs(snapshot.hands) do
106+
if h.side == enemy_side then
107+
out[#out + 1] = h
108+
end
109+
end
110+
return out
111+
end
112+
113+
-- Fallback for replays without hand-level data
90114
function MP.GHOST.get_enemy_score(ante)
91115
if not MP.GHOST.replay or not MP.GHOST.replay.ante_snapshots then return nil end
92116
local snapshot = MP.GHOST.replay.ante_snapshots[ante] or MP.GHOST.replay.ante_snapshots[tostring(ante)]
93117
if not snapshot then return nil end
94-
-- When flipped, the original player's score becomes the ghost target
95118
local key = MP.GHOST.flipped and "player_score" or "enemy_score"
96119
return snapshot[key]
97120
end
98121

122+
function MP.GHOST.init_playback(ante)
123+
local hands = MP.GHOST.get_enemy_hands(ante)
124+
MP.GHOST._hands = hands
125+
MP.GHOST._hand_idx = 0
126+
MP.GHOST._advancing = false
127+
if #hands > 0 then
128+
MP.GHOST._hand_idx = 1
129+
local score = MP.INSANE_INT.from_string(hands[1].score)
130+
MP.GAME.enemy.score = score
131+
MP.GAME.enemy.score_text = MP.INSANE_INT.to_string(score)
132+
MP.GAME.enemy.hands = hands[1].hands_left or 0
133+
return true
134+
else
135+
local score_str = MP.GHOST.get_enemy_score(ante)
136+
if score_str then
137+
MP.GAME.enemy.score = MP.INSANE_INT.from_string(score_str)
138+
MP.GAME.enemy.score_text = MP.INSANE_INT.to_string(MP.GAME.enemy.score)
139+
end
140+
return false
141+
end
142+
end
143+
144+
function MP.GHOST.advance_hand()
145+
if MP.GHOST._hand_idx >= #MP.GHOST._hands then return false end
146+
MP.GHOST._hand_idx = MP.GHOST._hand_idx + 1
147+
local entry = MP.GHOST._hands[MP.GHOST._hand_idx]
148+
local score = MP.INSANE_INT.from_string(entry.score)
149+
150+
G.E_MANAGER:add_event(Event({
151+
blockable = false, blocking = false,
152+
trigger = "ease", delay = 0.5,
153+
ref_table = MP.GAME.enemy.score,
154+
ref_value = "e_count",
155+
ease_to = score.e_count,
156+
func = function(t) return math.floor(t) end,
157+
}))
158+
G.E_MANAGER:add_event(Event({
159+
blockable = false, blocking = false,
160+
trigger = "ease", delay = 0.5,
161+
ref_table = MP.GAME.enemy.score,
162+
ref_value = "coeffiocient",
163+
ease_to = score.coeffiocient,
164+
func = function(t) return math.floor(t) end,
165+
}))
166+
G.E_MANAGER:add_event(Event({
167+
blockable = false, blocking = false,
168+
trigger = "ease", delay = 0.5,
169+
ref_table = MP.GAME.enemy.score,
170+
ref_value = "exponent",
171+
ease_to = score.exponent,
172+
func = function(t) return math.floor(t) end,
173+
}))
174+
175+
MP.GAME.enemy.hands = entry.hands_left or 0
176+
if MP.UI.juice_up_pvp_hud then MP.UI.juice_up_pvp_hud() end
177+
return true
178+
end
179+
180+
function MP.GHOST.playback_exhausted()
181+
return #MP.GHOST._hands == 0 or MP.GHOST._hand_idx >= #MP.GHOST._hands
182+
end
183+
184+
function MP.GHOST.has_hand_data()
185+
return #MP.GHOST._hands > 0
186+
end
187+
188+
-- Reads target from hands array directly, bypassing the eased score table.
189+
function MP.GHOST.current_target_big()
190+
if MP.GHOST._hand_idx < 1 or MP.GHOST._hand_idx > #MP.GHOST._hands then return to_big(0) end
191+
local entry = MP.GHOST._hands[MP.GHOST._hand_idx]
192+
local score = MP.INSANE_INT.from_string(entry.score)
193+
return to_big(score.coeffiocient * (10 ^ score.exponent))
194+
end
195+
99196
function MP.GHOST.get_nemesis_name()
100197
if not MP.GHOST.replay then return nil end
101198
if MP.GHOST.flipped then

rulesets/_rulesets.lua

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,8 @@ function MP.get_active_gamemode()
6161
return MP.LOBBY.config.gamemode
6262
elseif MP.is_practice_mode() then
6363
-- Ghost replay stores the gamemode directly
64-
if MP.GHOST.is_active() and MP.LOBBY.config.gamemode then
65-
return MP.LOBBY.config.gamemode
64+
if MP.GHOST.is_active() and MP.GHOST.gamemode then
65+
return MP.GHOST.gamemode
6666
end
6767
local ruleset_key = MP.SP and MP.SP.ruleset
6868
if ruleset_key and MP.Rulesets[ruleset_key] then return MP.Rulesets[ruleset_key].forced_gamemode end

ui/game/game_state.lua

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -217,13 +217,20 @@ function Game:update_hand_played(dt)
217217
if not ghost then
218218
MP.ACTIONS.play_hand(G.GAME.chips, G.GAME.current_round.hands_left)
219219
end
220-
-- For now, never advance to next round
220+
221221
if G.GAME.current_round.hands_left < 1 then
222222
if ghost then
223-
-- Auto-resolve PvP round locally
224-
local enemy_score = MP.GAME.enemy.score.coeffiocient * (10 ^ MP.GAME.enemy.score.exponent)
225-
local beat_ghost = to_big(G.GAME.chips) >= to_big(enemy_score)
226-
if beat_ghost then
223+
local target
224+
if MP.GHOST.has_hand_data() then
225+
target = MP.GHOST.current_target_big()
226+
else
227+
local es = MP.GAME.enemy.score
228+
target = to_big(es.coeffiocient * (10 ^ es.exponent))
229+
end
230+
local beat_current = to_big(G.GAME.chips) >= target
231+
local all_exhausted = MP.GHOST.playback_exhausted()
232+
233+
if beat_current and all_exhausted then
227234
MP.GAME.enemy.lives = MP.GAME.enemy.lives - 1
228235
if MP.GAME.enemy.lives <= 0 then
229236
MP.GAME.won = true
@@ -232,7 +239,6 @@ function Game:update_hand_played(dt)
232239
return true
233240
end
234241
else
235-
-- Mirror action_player_info: comeback bonus + no gold on loss
236242
if MP.LOBBY.config.gold_on_life_loss then
237243
MP.GAME.comeback_bonus_given = false
238244
MP.GAME.comeback_bonus = MP.GAME.comeback_bonus + 1
@@ -264,6 +270,60 @@ function Game:update_hand_played(dt)
264270
eval_hand_and_jokers()
265271
G.FUNCS.draw_from_hand_to_discard()
266272
end
273+
elseif ghost and MP.GHOST.has_hand_data() then
274+
local beat_current = to_big(G.GAME.chips) >= MP.GHOST.current_target_big()
275+
276+
if beat_current and MP.GHOST.playback_exhausted() then
277+
MP.GAME.enemy.lives = MP.GAME.enemy.lives - 1
278+
if MP.GAME.enemy.lives <= 0 then
279+
MP.GAME.won = true
280+
MP.MATCH_RECORD.finalize(true)
281+
win_game()
282+
return true
283+
end
284+
MP.GAME.end_pvp = true
285+
elseif beat_current and not MP.GHOST.playback_exhausted() and not MP.GHOST._advancing then
286+
MP.GHOST._advancing = true
287+
G.E_MANAGER:add_event(Event({
288+
blockable = false,
289+
blocking = false,
290+
trigger = "after",
291+
delay = 0.5,
292+
func = function()
293+
MP.GHOST.advance_hand()
294+
G.E_MANAGER:add_event(Event({
295+
blockable = false,
296+
blocking = false,
297+
trigger = "after",
298+
delay = 0.6,
299+
func = function()
300+
while to_big(G.GAME.chips) >= MP.GHOST.current_target_big() and not MP.GHOST.playback_exhausted() do
301+
MP.GHOST.advance_hand()
302+
end
303+
if to_big(G.GAME.chips) >= MP.GHOST.current_target_big() and MP.GHOST.playback_exhausted() then
304+
MP.GAME.enemy.lives = MP.GAME.enemy.lives - 1
305+
if MP.GAME.enemy.lives <= 0 then
306+
MP.GAME.won = true
307+
MP.MATCH_RECORD.finalize(true)
308+
win_game()
309+
MP.GHOST._advancing = false
310+
return true
311+
end
312+
MP.GAME.end_pvp = true
313+
end
314+
MP.GHOST._advancing = false
315+
return true
316+
end,
317+
}))
318+
return true
319+
end,
320+
}))
321+
end
322+
323+
if not MP.GAME.end_pvp and G.STATE == G.STATES.HAND_PLAYED then
324+
G.STATE_COMPLETE = false
325+
G.STATE = G.STATES.DRAW_TO_HAND
326+
end
267327
elseif not MP.GAME.end_pvp and G.STATE == G.STATES.HAND_PLAYED then
268328
G.STATE_COMPLETE = false
269329
G.STATE = G.STATES.DRAW_TO_HAND

ui/game/round.lua

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,8 @@ function reset_blinds()
5050
G.GAME.round_resets.blind_choices.Boss = mp_boss_choice or G.GAME.round_resets.blind_choices.Boss
5151
end
5252

53-
-- Inject ghost enemy score for PvP blinds
5453
if MP.GHOST.is_active() then
55-
local ghost_score_str = MP.GHOST.get_enemy_score(G.GAME.round_resets.ante)
56-
if ghost_score_str then
57-
MP.GAME.enemy.score = MP.INSANE_INT.from_string(ghost_score_str)
58-
MP.GAME.enemy.score_text = MP.INSANE_INT.to_string(MP.GAME.enemy.score)
59-
end
54+
MP.GHOST.init_playback(G.GAME.round_resets.ante)
6055
end
6156
end
6257

ui/main_menu/play_button/ghost_replay_picker.lua

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,6 @@ function G.FUNCS.load_previewed_ghost(e)
5050
MP.LoadReworks(ruleset_name)
5151
end
5252

53-
if replay.gamemode then MP.LOBBY.config.gamemode = replay.gamemode end
54-
5553
_preview_idx = nil
5654
_preview_flipped = false
5755
reopen_practice_menu()

0 commit comments

Comments
 (0)