Skip to content

Commit 4965a83

Browse files
authored
fix(table): keep deadlines running in river view (#89)
1 parent a474619 commit 4965a83

4 files changed

Lines changed: 14 additions & 191 deletions

File tree

src/main/java/top/ellan/mahjong/table/core/SessionActionDeadlineCoordinator.java

Lines changed: 2 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,6 @@ final class SessionActionDeadlineCoordinator {
3838
private final LongSupplier currentTimeMillis;
3939
private final Map<UUID, Long> remainingExtraMillis = new HashMap<>();
4040
private final Map<UUID, ActorDeadline> actorDeadlines = new HashMap<>();
41-
private final Map<UUID, SuspendedDeadline> suspendedDeadlines = new HashMap<>();
42-
private final Set<UUID> suspendedActors = new HashSet<>();
4341
private final Map<UUID, Integer> consecutiveAutomaticDiscards = new HashMap<>();
4442
private final Set<UUID> automaticDiscardActors = new HashSet<>();
4543
private String armedFingerprint;
@@ -54,10 +52,6 @@ final class SessionActionDeadlineCoordinator {
5452
}
5553

5654
synchronized void beginRound() {
57-
// An overhead river view may legitimately span the short gap between hands.
58-
// Reset hand-scoped clocks and idle history without dropping the explicit
59-
// suspension; the first action window of the new hand must remain frozen
60-
// until the player returns with Shift.
6155
this.resetRoundState();
6256
long extraMillis = this.thinkingBudget().extraMillis();
6357
for (UUID playerId : this.session.players()) {
@@ -121,10 +115,6 @@ synchronized void recordAction(UUID playerId) {
121115
if (deadline != null) {
122116
this.consumeExtra(playerId, deadline, now);
123117
}
124-
SuspendedDeadline suspended = this.suspendedDeadlines.remove(playerId);
125-
if (suspended != null) {
126-
this.consumeExtra(playerId, suspended.deadline(), suspended.suspendedAtMillis());
127-
}
128118
ActionWindow currentWindow = this.captureWindow();
129119
if (currentWindow == null) {
130120
this.reset();
@@ -148,91 +138,18 @@ synchronized long secondsRemaining(UUID playerId) {
148138
return 0L;
149139
}
150140
ActorDeadline deadline = this.actorDeadlines.get(playerId);
151-
if (deadline != null) {
152-
return secondsFromMillis(deadline.deadlineMillis() - this.currentTimeMillis.getAsLong());
153-
}
154-
SuspendedDeadline suspended = this.suspendedDeadlines.get(playerId);
155-
return suspended == null
141+
return deadline == null
156142
? 0L
157-
: secondsFromMillis(suspended.deadline().deadlineMillis() - suspended.suspendedAtMillis());
158-
}
159-
160-
/** Freezes this player's current and subsequent action windows until explicitly resumed. */
161-
synchronized void suspend(UUID playerId) {
162-
if (playerId == null || !this.suspendedActors.add(playerId)) {
163-
return;
164-
}
165-
ActionWindow window = this.captureWindow();
166-
if (window == null) {
167-
return;
168-
}
169-
long now = this.currentTimeMillis.getAsLong();
170-
if (!window.fingerprint().equals(this.armedFingerprint)) {
171-
this.armWindow(window, now);
172-
} else {
173-
this.ensureActorDeadlines(window, now);
174-
}
175-
}
176-
177-
/** Restores the frozen action with exactly the time that remained on entry. */
178-
synchronized void resume(UUID playerId) {
179-
if (playerId == null || !this.suspendedActors.remove(playerId)) {
180-
return;
181-
}
182-
long now = this.currentTimeMillis.getAsLong();
183-
SuspendedDeadline suspended = this.suspendedDeadlines.remove(playerId);
184-
ActionWindow window = this.captureWindow();
185-
if (window == null) {
186-
return;
187-
}
188-
if (!window.fingerprint().equals(this.armedFingerprint)) {
189-
if (suspended != null) {
190-
this.consumeExtra(playerId, suspended.deadline(), suspended.suspendedAtMillis());
191-
}
192-
this.armWindow(window, now);
193-
return;
194-
}
195-
if (suspended != null
196-
&& suspended.fingerprint().equals(window.fingerprint())
197-
&& window.actors().contains(playerId)) {
198-
ActorDeadline frozen = suspended.deadline();
199-
long elapsedBeforeSuspension = Math.max(0L, suspended.suspendedAtMillis() - frozen.startedAtMillis());
200-
long remainingMillis = Math.max(0L, frozen.deadlineMillis() - suspended.suspendedAtMillis());
201-
this.actorDeadlines.put(
202-
playerId,
203-
new ActorDeadline(
204-
Math.max(0L, now - elapsedBeforeSuspension),
205-
frozen.baseMillis(),
206-
frozen.extraAtStartMillis(),
207-
saturatedAdd(now, remainingMillis)
208-
)
209-
);
210-
} else {
211-
if (suspended != null) {
212-
this.consumeExtra(playerId, suspended.deadline(), suspended.suspendedAtMillis());
213-
}
214-
this.ensureActorDeadlines(window, now);
215-
}
216-
}
217-
218-
/** Drops a frozen action without restoring it, for disconnect/removal/table teardown. */
219-
synchronized void discardSuspension(UUID playerId) {
220-
if (playerId == null) {
221-
return;
222-
}
223-
this.suspendedActors.remove(playerId);
224-
this.suspendedDeadlines.remove(playerId);
143+
: secondsFromMillis(deadline.deadlineMillis() - this.currentTimeMillis.getAsLong());
225144
}
226145

227146
synchronized void reset() {
228147
this.armedFingerprint = null;
229148
this.actorDeadlines.clear();
230-
this.suspendedDeadlines.clear();
231149
}
232150

233151
synchronized void clear() {
234152
this.resetRoundState();
235-
this.suspendedActors.clear();
236153
}
237154

238155
private void resetRoundState() {
@@ -249,12 +166,7 @@ private void armWindow(ActionWindow window, long now) {
249166
for (Map.Entry<UUID, ActorDeadline> entry : List.copyOf(this.actorDeadlines.entrySet())) {
250167
this.consumeExtra(entry.getKey(), entry.getValue(), now);
251168
}
252-
for (Map.Entry<UUID, SuspendedDeadline> entry : List.copyOf(this.suspendedDeadlines.entrySet())) {
253-
SuspendedDeadline suspended = entry.getValue();
254-
this.consumeExtra(entry.getKey(), suspended.deadline(), suspended.suspendedAtMillis());
255-
}
256169
this.actorDeadlines.clear();
257-
this.suspendedDeadlines.clear();
258170
this.armedFingerprint = window.fingerprint();
259171
this.ensureActorDeadlines(window, now);
260172
}
@@ -271,27 +183,6 @@ private void ensureActorDeadlines(ActionWindow window, long now) {
271183
long extraMillis = window.phase() == ActionPhase.TURN
272184
? 0L
273185
: this.remainingExtraMillis.computeIfAbsent(playerId, ignored -> budget.extraMillis());
274-
if (this.suspendedActors.contains(playerId)) {
275-
ActorDeadline active = this.actorDeadlines.remove(playerId);
276-
this.suspendedDeadlines.compute(
277-
playerId,
278-
(ignored, current) -> current != null && current.fingerprint().equals(window.fingerprint())
279-
? current
280-
: new SuspendedDeadline(
281-
window.fingerprint(),
282-
active == null
283-
? new ActorDeadline(
284-
now,
285-
budget.baseMillis(),
286-
extraMillis,
287-
saturatedAdd(now, saturatedAdd(budget.baseMillis(), extraMillis))
288-
)
289-
: active,
290-
now
291-
)
292-
);
293-
continue;
294-
}
295186
this.actorDeadlines.computeIfAbsent(
296187
playerId,
297188
ignored -> new ActorDeadline(
@@ -583,13 +474,6 @@ private record ActorDeadline(
583474
) {
584475
}
585476

586-
private record SuspendedDeadline(
587-
String fingerprint,
588-
ActorDeadline deadline,
589-
long suspendedAtMillis
590-
) {
591-
}
592-
593477
private record ActionWindow(ActionPhase phase, String fingerprint, List<UUID> actors) {
594478
}
595479
}

src/main/java/top/ellan/mahjong/table/core/TableActionDeadlines.java

Lines changed: 0 additions & 26 deletions
This file was deleted.

src/main/java/top/ellan/mahjong/table/runtime/TableOverheadViewCoordinator.java

Lines changed: 2 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
import top.ellan.mahjong.config.PluginSettings;
2020
import top.ellan.mahjong.model.SeatWind;
2121
import top.ellan.mahjong.table.core.MahjongTableSession;
22-
import top.ellan.mahjong.table.core.TableActionDeadlines;
2322
import top.ellan.mahjong.table.core.TableOverheadViews;
2423
import top.ellan.mahjong.table.core.TableRuntimeServices;
2524

@@ -74,13 +73,7 @@ public void discard(UUID playerId) {
7473
if (playerId == null) {
7574
return;
7675
}
77-
ActiveView view = this.activeViews.remove(playerId);
78-
if (view != null) {
79-
// A disconnected player may retain their active seat. Restore the
80-
// action clock before unattended handling takes over on the next
81-
// table tick instead of leaving an orphaned camera suspension.
82-
TableActionDeadlines.resume(view.session(), playerId);
83-
}
76+
this.activeViews.remove(playerId);
8477
}
8578

8679
@Override
@@ -96,8 +89,6 @@ public void closeTable(String tableId) {
9689
Player player = Bukkit.getPlayer(entry.getKey());
9790
if (player != null && player.isOnline()) {
9891
this.plugin.scheduler().runEntity(player, () -> this.finishExit(player, view, false, true));
99-
} else {
100-
TableActionDeadlines.discardSuspension(view.session(), entry.getKey());
10192
}
10293
}
10394
}
@@ -112,8 +103,6 @@ public void closeAll() {
112103
Player player = Bukkit.getPlayer(entry.getKey());
113104
if (player != null && player.isOnline()) {
114105
this.plugin.scheduler().runEntity(player, () -> this.finishExit(player, view, false, true));
115-
} else {
116-
TableActionDeadlines.discardSuspension(view.session(), entry.getKey());
117106
}
118107
}
119108
}
@@ -131,8 +120,6 @@ public void shutdown() {
131120
// perform one direct best-effort restore without touching a
132121
// Bukkit world entity.
133122
this.finishExit(player, view, false, false);
134-
} else {
135-
TableActionDeadlines.discardSuspension(view.session(), entry.getKey());
136123
}
137124
}
138125
this.activeViews.clear();
@@ -183,10 +170,8 @@ private boolean enter(Player player, MahjongTableSession session, SeatWind wind)
183170
this.destroyCamera(player, camera);
184171
return false;
185172
}
186-
TableActionDeadlines.suspend(session, player.getUniqueId());
187173
if (!this.cameraBridge.setCamera(player, camera.entityID())) {
188174
this.activeViews.remove(player.getUniqueId(), view);
189-
TableActionDeadlines.resume(session, player.getUniqueId());
190175
this.restoreCameraAndDestroy(player, camera, true, 3, null);
191176
this.plugin.messages().send(player, "table.overhead.unavailable");
192177
return false;
@@ -197,9 +182,7 @@ private boolean enter(Player player, MahjongTableSession session, SeatWind wind)
197182
return true;
198183
} catch (Throwable throwable) {
199184
if (view != null) {
200-
if (this.activeViews.remove(player.getUniqueId(), view)) {
201-
TableActionDeadlines.resume(view.session(), player.getUniqueId());
202-
}
185+
this.activeViews.remove(player.getUniqueId(), view);
203186
}
204187
ActiveView current = this.activeViews.get(player.getUniqueId());
205188
if (camera != null && (current == null || current.camera() != camera)) {
@@ -256,15 +239,10 @@ private void finishExit(Player player, ActiveView view, boolean notify, boolean
256239
Runnable restored = () -> {
257240
ActiveView current = this.activeViews.get(player.getUniqueId());
258241
if (current == null) {
259-
TableActionDeadlines.resume(view.session(), player.getUniqueId());
260242
if (notify) {
261243
view.session().flushViewerActionsNow(player.getUniqueId());
262244
this.plugin.messages().send(player, "table.overhead.restored");
263245
}
264-
} else if (current.session() != view.session()) {
265-
// A new table may have acquired the player's camera while the
266-
// old restore retried. Do not resume the old table's action.
267-
TableActionDeadlines.discardSuspension(view.session(), player.getUniqueId());
268246
}
269247
};
270248
this.restoreCameraAndDestroy(player, view.camera(), retry, retry ? 3 : 0, restored);

src/test/kotlin/top/ellan/mahjong/table/core/SessionActionDeadlineCoordinatorTest.kt

Lines changed: 10 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,7 @@ class SessionActionDeadlineCoordinatorTest {
293293
}
294294

295295
@Test
296-
fun `viewing river freezes dingque fallback and shift return resumes exact remaining time`() {
296+
fun `viewing river leaves dingque deadline running`() {
297297
val clock = AtomicLong(3_000L)
298298
val session = baseSession(MahjongVariant.SICHUAN)
299299
`when`(session.players()).thenReturn(listOf(east))
@@ -323,25 +323,20 @@ class SessionActionDeadlineCoordinatorTest {
323323
clock.addAndGet(2_000L)
324324
assertEquals(6L, coordinator.secondsRemaining(east))
325325

326-
coordinator.suspend(east)
327-
clock.addAndGet(60_000L)
328-
coordinator.tick()
329-
330-
verify(session, never()).chooseSichuanMissingSuit(east, "suo")
331-
assertEquals(6L, coordinator.secondsRemaining(east))
332-
333-
coordinator.resume(east)
326+
// Entering the client-only river camera does not alter the action clock.
334327
clock.addAndGet(5_999L)
335328
coordinator.tick()
329+
336330
verify(session, never()).chooseSichuanMissingSuit(east, "suo")
331+
assertEquals(1L, coordinator.secondsRemaining(east))
337332

338333
clock.addAndGet(1L)
339334
coordinator.tick()
340335
verify(session).chooseSichuanMissingSuit(east, "suo")
341336
}
342337

343338
@Test
344-
fun `river suspension also freezes a new action window until the player returns`() {
339+
fun `new action window created while viewing river keeps its normal deadline`() {
345340
val clock = AtomicLong(3_000L)
346341
val dingquePending = AtomicBoolean(true)
347342
val session = turnSession(MahjongVariant.SICHUAN, east)
@@ -351,7 +346,6 @@ class SessionActionDeadlineCoordinatorTest {
351346
val coordinator = SessionActionDeadlineCoordinator(session, clock::get)
352347

353348
coordinator.tick()
354-
coordinator.suspend(east)
355349

356350
// Another seat's action advances the table from dingque to this player's turn
357351
// while the player is still inspecting the river.
@@ -360,19 +354,18 @@ class SessionActionDeadlineCoordinatorTest {
360354
coordinator.tick()
361355
assertEquals(60L, coordinator.secondsRemaining(east))
362356

363-
clock.addAndGet(120_000L)
357+
clock.addAndGet(59_999L)
364358
coordinator.tick()
365359
verify(session, never()).discard(east, 13)
366-
assertEquals(60L, coordinator.secondsRemaining(east))
360+
assertEquals(1L, coordinator.secondsRemaining(east))
367361

368-
coordinator.resume(east)
369-
clock.addAndGet(60_000L)
362+
clock.addAndGet(1L)
370363
coordinator.tick()
371364
verify(session).discard(east, 13)
372365
}
373366

374367
@Test
375-
fun `river suspension survives begin round and freezes the next hand until return`() {
368+
fun `new hand started while viewing river keeps its normal deadline`() {
376369
val clock = AtomicLong(3_000L)
377370
val round = AtomicInteger(0)
378371
val session = turnSession(MahjongVariant.RIICHI, east)
@@ -383,22 +376,16 @@ class SessionActionDeadlineCoordinatorTest {
383376

384377
coordinator.tick()
385378
clock.addAndGet(2_000L)
386-
coordinator.suspend(east)
387379
assertEquals(58L, coordinator.secondsRemaining(east))
388380

389381
round.incrementAndGet()
390382
coordinator.beginRound()
391383
assertEquals(60L, coordinator.secondsRemaining(east))
392384

393-
clock.addAndGet(120_000L)
394-
coordinator.tick()
395-
verify(session, never()).discard(east, 13)
396-
assertEquals(60L, coordinator.secondsRemaining(east))
397-
398-
coordinator.resume(east)
399385
clock.addAndGet(59_999L)
400386
coordinator.tick()
401387
verify(session, never()).discard(east, 13)
388+
assertEquals(1L, coordinator.secondsRemaining(east))
402389

403390
clock.addAndGet(1L)
404391
coordinator.tick()

0 commit comments

Comments
 (0)