Skip to content

Commit f241d3d

Browse files
committed
release: 1.2.0 - real GB opening procedure with two dice rolls and dead wall
1 parent ced742d commit f241d3d

17 files changed

Lines changed: 142 additions & 24 deletions

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,25 @@
11
# Changelog
22

3+
## 1.2.0 - 2026-06-16
4+
5+
GB/SICHUAN opening procedure and wall management overhaul to match real Chinese Official / Sichuan Mahjong rules.
6+
7+
中文更新日志:
8+
9+
- **两次掷骰开门**: GB/SICHUAN 开局现在按真实国标流程,庄家先掷两骰确定开门方位,被指定方位的玩家再掷两骰确定开门位置。`OpeningDiceRoll` 扩展为 4 参数(两组骰子),渲染层同步更新。
10+
- **14 张王牌区(dead wall)**: 牌墙末尾 14 张分离为 dead wall,仅用于补花/补杠替换。正常摸牌只从 live wall 前端取,live wall 摸完即流局。补花/补杠从 dead wall 尾部取牌,同时从 live wall 尾部补一张到 dead wall 前端,维持 dead wall 大小。
11+
- **门风正确轮转**: `buildFanRequest` / `buildWinRequest` / `toNativeMelds` 均已使用 `logicalSeatOf()` 将物理座位映射为逻辑门风(庄家 = EAST),从第二局起门风刻、圈风刻及原生引擎输入不再错位。
12+
- **四川定缺准备阶段**: 已有完整的交换→定缺两阶段准备流程,定缺后缺门花色牌必须先打。
13+
- **四川一炮多响**: 四川模式下 `allowMultiRon = true`,同一放铳可被多人同时荣和。
14+
15+
English Release Notes:
16+
17+
- **Two dice rolls for wall break**: GB/SICHUAN opening now follows real Chinese Official rules — the dealer rolls two dice to determine which wall side to open, then the player at that side rolls two more dice to determine the break position. `OpeningDiceRoll` expanded to 4 parameters (two pairs of dice); rendering layer updated accordingly.
18+
- **14-tile dead wall**: The last 14 tiles of the wall are reserved as the dead wall for flower/kong replacement draws only. Normal draws come from the live wall front; when the live wall is empty, the hand ends in exhaustive draw. Replacement draws take from the dead wall tail and replenish from the live wall tail to maintain dead wall size.
19+
- **Correct seat wind rotation**: `buildFanRequest` / `buildWinRequest` / `toNativeMelds` all use `logicalSeatOf()` to map physical seats to logical winds (dealer = EAST), so seat wind, round wind, and native engine inputs are correct from the second hand onward.
20+
- **Sichuan dingque preparation**: Full exchange → dingque two-phase preparation flow; after choosing the missing suit, tiles of that suit must be discarded first.
21+
- **Sichuan multi-ron**: In Sichuan mode, `allowMultiRon = true`, allowing multiple players to win on the same discard.
22+
323
## 1.1.3 - 2026-06-16
424

525
Hotfix for Paper 1.21.4+ servers where clicking any custom inventory (settlement, rule settings, table control) threw `IncompatibleClassChangeError`.

build.gradle.kts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ plugins {
1414
}
1515

1616
group = "top.ellan"
17-
version = "1.1.3"
17+
version = "1.2.0"
1818

1919
val minimumPaperDevBundleVersion = "1.20.1-R0.1-SNAPSHOT"
2020
val latestPaperDevBundleVersion = "26.2-rc-2.build.9-alpha"

src/main/java/top/ellan/mahjong/render/TableRenderSubject.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ public interface TableRenderSubject extends DisplayEntityRuntime {
5050

5151
int dicePoints();
5252

53+
default int breakDicePoints() {
54+
return this.dicePoints();
55+
}
56+
5357
int roundIndex();
5458

5559
int honbaCount();

src/main/java/top/ellan/mahjong/render/layout/TableRenderLayout.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -364,8 +364,9 @@ private static Point handTilePoint(
364364
private static int wallBreakTileIndex(TableRenderSnapshot snapshot) {
365365
int seatCount = SeatWind.values().length;
366366
int dicePoints = snapshot.dicePoints();
367+
int breakDice = snapshot.breakDicePoints();
367368
int directionIndex = 4 - (((dicePoints % seatCount) - 1 + snapshot.roundIndex()) % seatCount);
368-
return Math.floorMod(directionIndex * WALL_TILES_PER_SIDE + dicePoints * 2, TOTAL_WALL_TILES);
369+
return Math.floorMod(directionIndex * WALL_TILES_PER_SIDE + breakDice * 2, TOTAL_WALL_TILES);
369370
}
370371

371372
private static int doraIndicatorDeadWallIndex(int kanCount, int indicatorIndex) {

src/main/java/top/ellan/mahjong/render/scene/TableRenderer.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1694,8 +1694,9 @@ private static Location meldStartByDisplayDirection(Location center, SeatWind di
16941694
private static int wallBreakTileIndex(TableRenderSubject session) {
16951695
int seatCount = SeatWind.values().length;
16961696
int dicePoints = session.dicePoints();
1697+
int breakDice = session.breakDicePoints();
16971698
int directionIndex = 4 - (((dicePoints % seatCount) - 1 + session.roundIndex()) % seatCount);
1698-
return Math.floorMod(directionIndex * WALL_TILES_PER_SIDE + dicePoints * 2, TOTAL_WALL_TILES);
1699+
return Math.floorMod(directionIndex * WALL_TILES_PER_SIDE + breakDice * 2, TOTAL_WALL_TILES);
16991700
}
17001701

17011702
private static int deadWallAnchorSlot(TableRenderSubject session) {

src/main/java/top/ellan/mahjong/render/snapshot/TableRenderSnapshot.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ public record TableRenderSnapshot(
1717
int remainingWallCount,
1818
int kanCount,
1919
int dicePoints,
20+
int breakDicePoints,
2021
int roundIndex,
2122
int honbaCount,
2223
SeatWind dealerSeat,
@@ -30,6 +31,10 @@ public record TableRenderSnapshot(
3031
List<top.ellan.mahjong.model.MahjongTile> doraIndicators,
3132
EnumMap<SeatWind, TableSeatRenderSnapshot> seats
3233
) {
34+
public int breakDicePoints() {
35+
return this.breakDicePoints;
36+
}
37+
3338
public TableSeatRenderSnapshot seat(SeatWind wind) {
3439
return this.seats.get(wind);
3540
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -644,6 +644,11 @@ public SeatWind openDoorSeat() {
644644
);
645645
}
646646

647+
@Override
648+
public int breakDicePoints() {
649+
return this.intFromRoundController(TableRoundController::dicePoints2);
650+
}
651+
647652
public int honbaCount() {
648653
return this.intFromRoundController(TableRoundController::honbaCount);
649654
}

src/main/java/top/ellan/mahjong/table/core/round/GbRoundSupport.java

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
import java.util.concurrent.ThreadLocalRandom;
1010

1111
final class GbRoundSupport {
12+
static final int DEAD_WALL_SIZE = 14;
13+
1214
private GbRoundSupport() {
1315
}
1416

@@ -190,13 +192,17 @@ static List<MahjongTile> buildWall(GbRuleProfile profile) {
190192
}
191193

192194
static List<MahjongTile> reorderWallForDice(List<MahjongTile> wall, int dicePoints, int roundIndex) {
195+
return reorderWallForDice(wall, dicePoints, dicePoints, roundIndex);
196+
}
197+
198+
static List<MahjongTile> reorderWallForDice(List<MahjongTile> wall, int directionDicePoints, int breakDicePoints, int roundIndex) {
193199
if (wall == null || wall.isEmpty()) {
194200
return List.of();
195201
}
196202
int seatCount = SeatWind.values().length;
197203
int wallTilesPerSide = wall.size() / seatCount;
198-
int directionIndex = seatCount - (((dicePoints % seatCount) - 1 + roundIndex) % seatCount);
199-
int startingStackIndex = 2 * dicePoints;
204+
int directionIndex = seatCount - (((directionDicePoints % seatCount) - 1 + roundIndex) % seatCount);
205+
int startingStackIndex = 2 * breakDicePoints;
200206
List<MahjongTile> reordered = new ArrayList<>(wall.size());
201207
for (int i = 0; i < wall.size(); i++) {
202208
int tileIndex = Math.floorMod(directionIndex * wallTilesPerSide + startingStackIndex + i, wall.size());

src/main/java/top/ellan/mahjong/table/core/round/GbTableRoundController.java

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,11 @@ private enum SichuanPreparationPhase {
8484
private final Map<UUID, SichuanSuit> chosenMissingSuits = new HashMap<>();
8585
private final SichuanRulesEngine sichuanRulesEngine;
8686
private List<MahjongTile> wall = List.of();
87+
private List<MahjongTile> deadWall = List.of();
8788
private boolean started;
8889
private boolean gameFinished;
8990
private int dicePoints;
91+
private int dicePoints2;
9092
private int currentPlayerIndex;
9193
private int kanCount;
9294
private RoundResolution lastResolution;
@@ -187,10 +189,18 @@ public boolean gameFinished() {
187189
public void startRound() {
188190
OpeningDiceRoll diceRoll = this.pendingDiceRoll;
189191
this.pendingDiceRoll = null;
190-
this.dicePoints = diceRoll == null
191-
? GbRoundSupport.requireValidDicePoints(this.dicePointsSupplier.getAsInt())
192-
: diceRoll.total();
193-
this.wall = GbRoundSupport.reorderWallForDice(this.wallSupplier.get(), this.dicePoints, this.round.getRound());
192+
if (diceRoll == null) {
193+
int roll1 = GbRoundSupport.requireValidDicePoints(this.dicePointsSupplier.getAsInt());
194+
int roll2 = GbRoundSupport.requireValidDicePoints(this.dicePointsSupplier.getAsInt());
195+
this.dicePoints = roll1;
196+
this.dicePoints2 = roll2;
197+
} else {
198+
this.dicePoints = diceRoll.total();
199+
this.dicePoints2 = diceRoll.total2();
200+
}
201+
List<MahjongTile> fullWall = GbRoundSupport.reorderWallForDice(this.wallSupplier.get(), this.dicePoints, this.dicePoints2, this.round.getRound());
202+
this.deadWall = List.copyOf(fullWall.subList(fullWall.size() - GbRoundSupport.DEAD_WALL_SIZE, fullWall.size()));
203+
this.wall = List.copyOf(fullWall.subList(0, fullWall.size() - GbRoundSupport.DEAD_WALL_SIZE));
194204
this.pendingReactionWindow = null;
195205
this.lastResolution = null;
196206
this.started = true;
@@ -376,6 +386,11 @@ public int dicePoints() {
376386
return this.dicePoints;
377387
}
378388

389+
@Override
390+
public int dicePoints2() {
391+
return this.dicePoints2;
392+
}
393+
379394
@Override
380395
public int kanCount() {
381396
return this.kanCount;
@@ -424,10 +439,13 @@ public List<MahjongTile> discards(UUID playerId) {
424439

425440
@Override
426441
public List<MahjongTile> remainingWall() {
427-
List<MahjongTile> hiddenWall = new ArrayList<>(this.wall.size());
442+
List<MahjongTile> hiddenWall = new ArrayList<>(this.wall.size() + this.deadWall.size());
428443
for (int i = 0; i < this.wall.size(); i++) {
429444
hiddenWall.add(MahjongTile.UNKNOWN);
430445
}
446+
for (int i = 0; i < this.deadWall.size(); i++) {
447+
hiddenWall.add(MahjongTile.UNKNOWN);
448+
}
431449
return List.copyOf(hiddenWall);
432450
}
433451

@@ -1755,18 +1773,18 @@ private boolean drawTile(UUID playerId, boolean fromBack, boolean markAfterKong,
17551773
if (playerId == null || this.wall.isEmpty()) {
17561774
return false;
17571775
}
1758-
boolean nextFromBack = fromBack;
1776+
if (fromBack) {
1777+
return this.drawReplacementTile(playerId, markAfterKong, markAsDrawn);
1778+
}
17591779
while (!this.wall.isEmpty()) {
17601780
List<MahjongTile> mutableWall = new ArrayList<>(this.wall);
1761-
MahjongTile tile = nextFromBack ? mutableWall.remove(mutableWall.size() - 1) : mutableWall.remove(0);
1781+
MahjongTile tile = mutableWall.remove(0);
17621782
this.wall = List.copyOf(mutableWall);
17631783
if (tile.isFlower()) {
17641784
if (!this.ruleProfile.includesFlowers()) {
1765-
nextFromBack = true;
17661785
continue;
17671786
}
17681787
this.flowers.get(playerId).add(tile);
1769-
nextFromBack = true;
17701788
continue;
17711789
}
17721790
this.hands.get(playerId).add(tile);
@@ -1778,6 +1796,32 @@ private boolean drawTile(UUID playerId, boolean fromBack, boolean markAfterKong,
17781796
return false;
17791797
}
17801798

1799+
private boolean drawReplacementTile(UUID playerId, boolean markAfterKong, boolean markAsDrawn) {
1800+
if (this.deadWall.isEmpty()) {
1801+
return false;
1802+
}
1803+
List<MahjongTile> mutableDeadWall = new ArrayList<>(this.deadWall);
1804+
MahjongTile tile = mutableDeadWall.remove(mutableDeadWall.size() - 1);
1805+
if (!this.wall.isEmpty()) {
1806+
List<MahjongTile> mutableWall = new ArrayList<>(this.wall);
1807+
mutableDeadWall.add(0, mutableWall.remove(mutableWall.size() - 1));
1808+
this.wall = List.copyOf(mutableWall);
1809+
}
1810+
this.deadWall = List.copyOf(mutableDeadWall);
1811+
if (tile.isFlower()) {
1812+
if (!this.ruleProfile.includesFlowers()) {
1813+
return this.drawReplacementTile(playerId, markAfterKong, markAsDrawn);
1814+
}
1815+
this.flowers.get(playerId).add(tile);
1816+
return this.drawReplacementTile(playerId, markAfterKong, markAsDrawn);
1817+
}
1818+
this.hands.get(playerId).add(tile);
1819+
this.hasDrawnTile.put(playerId, markAsDrawn);
1820+
this.sortHand(playerId);
1821+
this.afterKanTsumoPlayer = markAfterKong ? playerId : null;
1822+
return true;
1823+
}
1824+
17811825
private void finishExhaustiveDraw() {
17821826
this.pendingReactionWindow = null;
17831827
this.started = false;

src/main/java/top/ellan/mahjong/table/core/round/OpeningDiceRoll.java

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,29 @@
22

33
import java.util.concurrent.ThreadLocalRandom;
44

5-
public record OpeningDiceRoll(int firstDie, int secondDie) {
5+
public record OpeningDiceRoll(int firstDie, int secondDie, int firstDie2, int secondDie2) {
66
public OpeningDiceRoll {
7-
if (firstDie < 1 || firstDie > 6 || secondDie < 1 || secondDie > 6) {
7+
if (firstDie < 1 || firstDie > 6 || secondDie < 1 || secondDie > 6
8+
|| firstDie2 < 1 || firstDie2 > 6 || secondDie2 < 1 || secondDie2 > 6) {
89
throw new IllegalArgumentException("Dice values must be between 1 and 6");
910
}
1011
}
1112

13+
public OpeningDiceRoll(int firstDie, int secondDie) {
14+
this(firstDie, secondDie, firstDie, secondDie);
15+
}
16+
1217
public int total() {
1318
return this.firstDie + this.secondDie;
1419
}
1520

21+
public int total2() {
22+
return this.firstDie2 + this.secondDie2;
23+
}
24+
1625
public static OpeningDiceRoll random() {
1726
ThreadLocalRandom random = ThreadLocalRandom.current();
18-
return new OpeningDiceRoll(random.nextInt(1, 7), random.nextInt(1, 7));
27+
return new OpeningDiceRoll(random.nextInt(1, 7), random.nextInt(1, 7), random.nextInt(1, 7), random.nextInt(1, 7));
1928
}
2029
}
2130

0 commit comments

Comments
 (0)