diff --git a/realty-backend-api/src/main/java/io/github/md5sha256/realty/api/RealtyBackend.java b/realty-backend-api/src/main/java/io/github/md5sha256/realty/api/RealtyBackend.java index 5f3641c..0b540db 100644 --- a/realty-backend-api/src/main/java/io/github/md5sha256/realty/api/RealtyBackend.java +++ b/realty-backend-api/src/main/java/io/github/md5sha256/realty/api/RealtyBackend.java @@ -298,6 +298,8 @@ void rollbackRent(@NotNull String worldGuardRegionId, sealed interface UnrentResult { record Success(double refund, @NotNull UUID tenantId, @NotNull UUID landlordId) implements UnrentResult {} record NoLeaseholdContract() implements UnrentResult {} + /** The lease is scheduled for termination; it can only end via the sweep on the effective date. */ + record Terminating() implements UnrentResult {} record UpdateFailed() implements UnrentResult {} } diff --git a/realty-backend/src/main/java/io/github/md5sha256/realty/database/RealtyBackendImpl.java b/realty-backend/src/main/java/io/github/md5sha256/realty/database/RealtyBackendImpl.java index 15ded67..f337c3c 100644 --- a/realty-backend/src/main/java/io/github/md5sha256/realty/database/RealtyBackendImpl.java +++ b/realty-backend/src/main/java/io/github/md5sha256/realty/database/RealtyBackendImpl.java @@ -807,6 +807,12 @@ public void rollbackRent(@NotNull String worldGuardRegionId, if (lease == null) { return new UnrentResult.NoLeaseholdContract(); } + // A scheduled termination — an eviction in particular — must run its notice period out. + // Without this guard the tenant could unrent (which clears terminationEffectiveDate along + // with the tenant) and immediately re-rent, unilaterally cancelling their own eviction. + if (lease.terminationEffectiveDate() != null) { + return new UnrentResult.Terminating(); + } long totalSeconds = lease.durationSeconds(); // Pro-rata refund of the UNUSED portion of the current period. Renewals push // endDate out by another durationSeconds each (see renewLeasehold) but are not diff --git a/realty-backend/src/main/java/io/github/md5sha256/realty/database/mapper/LeaseholdContractMapper.java b/realty-backend/src/main/java/io/github/md5sha256/realty/database/mapper/LeaseholdContractMapper.java index 27756c2..cded96e 100644 --- a/realty-backend/src/main/java/io/github/md5sha256/realty/database/mapper/LeaseholdContractMapper.java +++ b/realty-backend/src/main/java/io/github/md5sha256/realty/database/mapper/LeaseholdContractMapper.java @@ -57,6 +57,7 @@ int scheduleTermination(@NotNull String worldGuardRegionId, /** Leaseholds whose scheduled termination date has elapsed, due to be ended by the sweep. */ @NotNull List selectTerminatedLeaseholds(); + /** Clears the tenant, guarded on the caller still being the tenant and no termination pending. */ int unrentRegion(@NotNull String worldGuardRegionId, @NotNull UUID worldId, @NotNull UUID tenantId); diff --git a/realty-backend/src/main/java/io/github/md5sha256/realty/database/maria/mapper/MariaLeaseholdContractMapper.java b/realty-backend/src/main/java/io/github/md5sha256/realty/database/maria/mapper/MariaLeaseholdContractMapper.java index eaf019b..059e822 100644 --- a/realty-backend/src/main/java/io/github/md5sha256/realty/database/maria/mapper/MariaLeaseholdContractMapper.java +++ b/realty-backend/src/main/java/io/github/md5sha256/realty/database/maria/mapper/MariaLeaseholdContractMapper.java @@ -257,6 +257,7 @@ int clearTermination(@Param("worldGuardRegionId") @NotNull String worldGuardRegi WHERE rr.worldGuardRegionId = #{worldGuardRegionId} AND rr.worldId = #{worldId} AND lc.tenantId = #{tenantId} + AND lc.terminationEffectiveDate IS NULL """) int unrentRegion(@Param("worldGuardRegionId") @NotNull String worldGuardRegionId, @Param("worldId") @NotNull UUID worldId, diff --git a/realty-backend/src/test/java/io/github/md5sha256/realty/database/RealtyBackendImplTest.java b/realty-backend/src/test/java/io/github/md5sha256/realty/database/RealtyBackendImplTest.java index 3786401..059971c 100644 --- a/realty-backend/src/test/java/io/github/md5sha256/realty/database/RealtyBackendImplTest.java +++ b/realty-backend/src/test/java/io/github/md5sha256/realty/database/RealtyBackendImplTest.java @@ -12,6 +12,7 @@ import io.github.md5sha256.realty.api.RealtyBackend.PayOfferResult; import io.github.md5sha256.realty.api.RealtyBackend.RegionInfo; import io.github.md5sha256.realty.database.entity.FreeholdContractBidPaymentEntity; +import io.github.md5sha256.realty.database.entity.LeaseholdContractEntity; import io.github.md5sha256.realty.database.entity.FreeholdContractOfferPaymentEntity; import org.apache.ibatis.session.SqlSession; import org.junit.jupiter.api.Assertions; @@ -320,6 +321,30 @@ void scheduleBlocksExtensionAndSweepEndsLease() { Assertions.assertNull(logic.getLeaseholdContract(regionId, WORLD_ID).tenantId()); } + @Test + @DisplayName("a tenant under eviction cannot unrent their way out and re-rent") + void unrentBlockedWhileTerminating() { + String regionId = uniqueRegionId(); + logic.createLeasehold(regionId, WORLD_ID, 200.0, 3600, 5, PLAYER_A); + logic.rentRegion(regionId, WORLD_ID, PLAYER_B); + + LocalDateTime effective = LocalDateTime.now().plusDays(7); + Assertions.assertInstanceOf(RealtyBackend.TerminateLeaseholdResult.Success.class, + logic.terminateLease(regionId, WORLD_ID, effective, effective, "landlord")); + + // Pre-fix this succeeded, clearing the termination along with the tenant. + Assertions.assertInstanceOf(RealtyBackend.UnrentResult.Terminating.class, + logic.unrentRegion(regionId, WORLD_ID, PLAYER_B)); + + LeaseholdContractEntity lease = logic.getLeaseholdContract(regionId, WORLD_ID); + Assertions.assertEquals(PLAYER_B, lease.tenantId(), "Tenant must still be on the lease"); + Assertions.assertNotNull(lease.terminationEffectiveDate(), "Eviction must still be pending"); + + // ...so the region is still occupied and cannot be re-rented. + Assertions.assertInstanceOf(RealtyBackend.RentResult.AlreadyOccupied.class, + logic.rentRegion(regionId, WORLD_ID, PLAYER_B)); + } + @Test @DisplayName("cannot terminate a vacant lease") void cannotTerminateVacant() { diff --git a/realty-paper-api/src/main/java/io/github/md5sha256/realty/api/RealtyPaperApi.java b/realty-paper-api/src/main/java/io/github/md5sha256/realty/api/RealtyPaperApi.java index 612e71f..88b60b0 100644 --- a/realty-paper-api/src/main/java/io/github/md5sha256/realty/api/RealtyPaperApi.java +++ b/realty-paper-api/src/main/java/io/github/md5sha256/realty/api/RealtyPaperApi.java @@ -75,6 +75,8 @@ sealed interface UnrentResult { record Success(double refund, @NotNull String regionId, @NotNull UUID landlordId) implements UnrentResult {} record NoLeaseholdContract(@NotNull String regionId) implements UnrentResult {} + /** The lease is scheduled for termination and can only end on the effective date. */ + record Terminating(@NotNull String regionId) implements UnrentResult {} record RefundFailed(@NotNull String error) implements UnrentResult {} record UpdateFailed(@NotNull String regionId) implements UnrentResult {} record Error(@NotNull String message) implements UnrentResult {} diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/api/RealtyPaperApiImpl.java b/realty-paper/src/main/java/io/github/md5sha256/realty/api/RealtyPaperApiImpl.java index 7217376..1380ba5 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/api/RealtyPaperApiImpl.java +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/api/RealtyPaperApiImpl.java @@ -313,6 +313,9 @@ public void setSafeBlockPredicate(@NotNull Predicate predicate) { case RealtyBackend.UnrentResult.NoLeaseholdContract ignored -> CompletableFuture.completedFuture( (UnrentResult) new UnrentResult.NoLeaseholdContract(regionId)); + case RealtyBackend.UnrentResult.Terminating ignored -> + CompletableFuture.completedFuture( + (UnrentResult) new UnrentResult.Terminating(regionId)); case RealtyBackend.UnrentResult.UpdateFailed ignored -> CompletableFuture.completedFuture( (UnrentResult) new UnrentResult.UpdateFailed(regionId)); diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/command/UnrentCommand.java b/realty-paper/src/main/java/io/github/md5sha256/realty/command/UnrentCommand.java index 288f34a..4655a13 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/command/UnrentCommand.java +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/command/UnrentCommand.java @@ -74,6 +74,9 @@ private void execute(@NotNull CommandContext ctx) { case RealtyPaperApi.UnrentResult.NoLeaseholdContract noContract -> sender.sendMessage(messages.messageFor(MessageKeys.UNRENT_NO_LEASEHOLD_CONTRACT, Placeholder.unparsed("region", noContract.regionId()))); + case RealtyPaperApi.UnrentResult.Terminating terminating -> + sender.sendMessage(messages.messageFor(MessageKeys.UNRENT_TERMINATING, + Placeholder.unparsed("region", terminating.regionId()))); case RealtyPaperApi.UnrentResult.RefundFailed failed -> sender.sendMessage(messages.messageFor(MessageKeys.UNRENT_REFUND_FAILED, Placeholder.unparsed("error", failed.error()))); diff --git a/realty-paper/src/main/java/io/github/md5sha256/realty/localisation/MessageKeys.java b/realty-paper/src/main/java/io/github/md5sha256/realty/localisation/MessageKeys.java index c71878f..1d46039 100644 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/localisation/MessageKeys.java +++ b/realty-paper/src/main/java/io/github/md5sha256/realty/localisation/MessageKeys.java @@ -285,6 +285,7 @@ private MessageKeys() {} public static final String UNRENT_SUCCESS = "unrent.success"; public static final String UNRENT_NO_LEASEHOLD_CONTRACT = "unrent.no-leasehold-contract"; public static final String UNRENT_NOT_TENANT = "unrent.not-tenant"; + public static final String UNRENT_TERMINATING = "unrent.terminating"; public static final String UNRENT_UPDATE_FAILED = "unrent.update-failed"; public static final String UNRENT_REFUND_FAILED = "unrent.refund-failed"; public static final String UNRENT_ERROR = "unrent.error"; diff --git a/realty-paper/src/main/resources/messages.yml b/realty-paper/src/main/resources/messages.yml index 224b4a1..93e1712 100644 --- a/realty-paper/src/main/resources/messages.yml +++ b/realty-paper/src/main/resources/messages.yml @@ -619,6 +619,8 @@ unrent: $.' no-leasehold-contract: Region is not a leasehold. not-tenant: You are not the tenant of region . + terminating: Region is scheduled for termination + and can no longer be unrented. It ends on the scheduled date. update-failed: Failed to unrent region . refund-failed: ' Failed to process refund: ' error: ' Failed to unrent region: ' diff --git a/realty-paper/src/test/java/io/github/md5sha256/realty/api/RealtyPaperApiImplTest.java b/realty-paper/src/test/java/io/github/md5sha256/realty/api/RealtyPaperApiImplTest.java index abab5d7..7295dcf 100644 --- a/realty-paper/src/test/java/io/github/md5sha256/realty/api/RealtyPaperApiImplTest.java +++ b/realty-paper/src/test/java/io/github/md5sha256/realty/api/RealtyPaperApiImplTest.java @@ -387,6 +387,21 @@ void noLeaseholdContract() { Assertions.assertInstanceOf(RealtyPaperApi.UnrentResult.NoLeaseholdContract.class, result); } + @Test + @DisplayName("returns Terminating and leaves the region alone while an eviction is pending") + void terminating() { + when(realtyApi.unrentRegion(REGION_ID, WORLD_ID, TENANT_ID)) + .thenReturn(new RealtyBackend.UnrentResult.Terminating()); + + protectedRegion.getOwners().addPlayer(TENANT_ID); + + RealtyPaperApi.UnrentResult result = api.unrent(wgRegion, TENANT_ID).join(); + + Assertions.assertInstanceOf(RealtyPaperApi.UnrentResult.Terminating.class, result); + Assertions.assertEquals(1, protectedRegion.getOwners().size()); + verify(regionProfileService, never()).applyFlags(any(), any(), any()); + } + @Test @DisplayName("success clears owners and applies FOR_LEASE flags") void success() {