Exercise short-stack calibration paths
This commit is contained in:
@@ -15,6 +15,7 @@ export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
|
|||||||
./gradlew :sim:test # fast calibration-policy tests
|
./gradlew :sim:test # fast calibration-policy tests
|
||||||
./gradlew :sim:run --args="styles" # enforced controlled style experiment
|
./gradlew :sim:run --args="styles" # enforced controlled style experiment
|
||||||
./gradlew :sim:run --args="calibrate" # enforced 4×100k fixed-pool skill + style calibration
|
./gradlew :sim:run --args="calibrate" # enforced 4×100k fixed-pool skill + style calibration
|
||||||
|
./gradlew :sim:run --args="short-stacks" # persistent stacks; asserts side-pot/call-clamp coverage
|
||||||
./gradlew :app:assembleDebug # build the APK
|
./gradlew :app:assembleDebug # build the APK
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -48,6 +49,8 @@ export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
|
|||||||
4. **The simulator is how bots get tuned.** Run it after any bot change, across
|
4. **The simulator is how bots get tuned.** Run it after any bot change, across
|
||||||
several seeds — a single seed will happily agree with a wrong conclusion.
|
several seeds — a single seed will happily agree with a wrong conclusion.
|
||||||
The quick 50k run is diagnostic; only `calibrate` is an enforced result.
|
The quick 50k run is diagnostic; only `calibrate` is an enforced result.
|
||||||
|
Its paired skill comparisons reset equal stacks to isolate skill, then a
|
||||||
|
mandatory persistent-stack pass verifies that short-stack pricing is live.
|
||||||
5. **A skill parameter must not smuggle in a style change.** Several bugs came
|
5. **A skill parameter must not smuggle in a style change.** Several bugs came
|
||||||
from exactly this: `positionAwareness` silently reduced hands played,
|
from exactly this: `positionAwareness` silently reduced hands played,
|
||||||
`potOddsRespect` systematically loosened weak players (which is a *winning*
|
`potOddsRespect` systematically loosened weak players (which is a *winning*
|
||||||
@@ -77,6 +80,10 @@ export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
|
|||||||
- Small samples lie. 1,000 hands is not enough to rank profiles. Use the paired
|
- Small samples lie. 1,000 hands is not enough to rank profiles. Use the paired
|
||||||
4×100k fixed-opponent calibration before accepting a skill change; it computes
|
4×100k fixed-opponent calibration before accepting a skill change; it computes
|
||||||
confidence bounds and exits nonzero when the contract fails.
|
confidence bounds and exits nonzero when the contract fails.
|
||||||
|
- Equal starting stacks are deliberate in the paired skill experiment, but they
|
||||||
|
cannot exercise side-pot pricing. `calibrate` therefore finishes with 3,000
|
||||||
|
persistent-stack hands and fails unless both `eligiblePot != pot` and
|
||||||
|
`toCall > stack` occur. `:sim:test` pins the same paths at a fixed seed.
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
@@ -95,6 +102,8 @@ export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
|
|||||||
are allowed to overlap, but both must beat Intermediate and Intermediate must
|
are allowed to overlap, but both must beat Intermediate and Intermediate must
|
||||||
beat Beginner with a positive 95% lower confidence bound. Current lower bounds
|
beat Beginner with a positive 95% lower confidence bound. Current lower bounds
|
||||||
are +24.42, +11.01, and +24.67 bb/100 respectively.
|
are +24.42, +11.01, and +24.67 bb/100 respectively.
|
||||||
|
- Persistent-stack calibration currently reaches 351 decisions with inaccessible
|
||||||
|
side-pot chips and stack-clamped calls in 3,000 hands (seed 20260729).
|
||||||
- Expert differs by mechanism: it alone maintains opponent reads. The aggression
|
- Expert differs by mechanism: it alone maintains opponent reads. The aggression
|
||||||
prior is measured by the controlled neutral TAG experiment (0.231 observed,
|
prior is measured by the controlled neutral TAG experiment (0.231 observed,
|
||||||
0.22 configured), not selected because it looks plausible.
|
0.22 configured), not selected because it looks plausible.
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ class ActionButtonsTest {
|
|||||||
stack = stack,
|
stack = stack,
|
||||||
canCheck = canCheck,
|
canCheck = canCheck,
|
||||||
canRaise = canRaise,
|
canRaise = canRaise,
|
||||||
|
activeOpponents = 1,
|
||||||
|
seatsActingAfter = 0,
|
||||||
|
bigBlind = 2,
|
||||||
|
eligiblePot = 40,
|
||||||
|
currentBet = if (canCheck) 0 else toCall,
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---------- the raise omission ----------
|
// ---------- the raise omission ----------
|
||||||
@@ -114,6 +119,11 @@ class CallCostTest {
|
|||||||
stack = stack,
|
stack = stack,
|
||||||
canCheck = false,
|
canCheck = false,
|
||||||
canRaise = false,
|
canRaise = false,
|
||||||
|
activeOpponents = 1,
|
||||||
|
seatsActingAfter = 0,
|
||||||
|
bigBlind = 2,
|
||||||
|
eligiblePot = 100,
|
||||||
|
currentBet = toCall,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -50,6 +50,11 @@ class LiveOfferTest {
|
|||||||
stack = 500,
|
stack = 500,
|
||||||
canCheck = false,
|
canCheck = false,
|
||||||
canRaise = true,
|
canRaise = true,
|
||||||
|
activeOpponents = 1,
|
||||||
|
seatsActingAfter = 0,
|
||||||
|
bigBlind = 2,
|
||||||
|
eligiblePot = 40,
|
||||||
|
currentBet = 10,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -30,13 +30,13 @@ data class DecisionOffer(
|
|||||||
val canCheck: Boolean,
|
val canCheck: Boolean,
|
||||||
val canRaise: Boolean,
|
val canRaise: Boolean,
|
||||||
/** Public table state needed by deterministic post-action analysis. */
|
/** Public table state needed by deterministic post-action analysis. */
|
||||||
val activeOpponents: Int = 1,
|
val activeOpponents: Int,
|
||||||
val seatsActingAfter: Int = 0,
|
val seatsActingAfter: Int,
|
||||||
val bigBlind: Int = 2,
|
val bigBlind: Int,
|
||||||
/** Current pot this seat can contest if it calls; excludes the call and higher side pots. */
|
/** Current pot this seat can contest if it calls; excludes the call and higher side pots. */
|
||||||
val eligiblePot: Int = pot,
|
val eligiblePot: Int,
|
||||||
/** Highest amount committed by any seat in the current betting round. */
|
/** Highest amount committed by any seat in the current betting round. */
|
||||||
val currentBet: Int = if (street == Street.PREFLOP) bigBlind else 0,
|
val currentBet: Int,
|
||||||
) {
|
) {
|
||||||
/**
|
/**
|
||||||
* What calling actually costs.
|
* What calling actually costs.
|
||||||
|
|||||||
@@ -82,9 +82,9 @@ class DecisionContext(
|
|||||||
* call. The call itself is not included. Excludes unmatched excess and side
|
* call. The call itself is not included. Excludes unmatched excess and side
|
||||||
* pots above this seat's final level.
|
* pots above this seat's final level.
|
||||||
*/
|
*/
|
||||||
val eligiblePot: Int = pot,
|
val eligiblePot: Int,
|
||||||
/** Highest amount committed by any seat in the current betting round. */
|
/** Highest amount committed by any seat in the current betting round. */
|
||||||
val currentBet: Int = if (street == Street.PREFLOP) bigBlind else 0,
|
val currentBet: Int,
|
||||||
) {
|
) {
|
||||||
val hole: IntArray get() = seat.hole
|
val hole: IntArray get() = seat.hole
|
||||||
val stack: Int get() = seat.stack
|
val stack: Int get() = seat.stack
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ class DecisionTraceTest {
|
|||||||
handNumber = 1,
|
handNumber = 1,
|
||||||
decisionToken = 1,
|
decisionToken = 1,
|
||||||
bettingReopened = true,
|
bettingReopened = true,
|
||||||
|
eligiblePot = 80,
|
||||||
|
currentBet = if (street == Street.PREFLOP) 2 else 20,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,6 +147,7 @@ class DecisionTraceTest {
|
|||||||
decisionToken = 1,
|
decisionToken = 1,
|
||||||
bettingReopened = false,
|
bettingReopened = false,
|
||||||
eligiblePot = 40,
|
eligiblePot = 40,
|
||||||
|
currentBet = 100,
|
||||||
)
|
)
|
||||||
|
|
||||||
bot.act(ctx)
|
bot.act(ctx)
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ class ProfileBehaviourTest {
|
|||||||
handNumber = it + 1,
|
handNumber = it + 1,
|
||||||
decisionToken = (it + 1).toLong(),
|
decisionToken = (it + 1).toLong(),
|
||||||
bettingReopened = true,
|
bettingReopened = true,
|
||||||
|
eligiblePot = 3,
|
||||||
|
currentBet = 2,
|
||||||
)
|
)
|
||||||
val action = bot.act(ctx)
|
val action = bot.act(ctx)
|
||||||
if (action.type != ActionType.FOLD) entered++
|
if (action.type != ActionType.FOLD) entered++
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ class FoldAndTokenTest {
|
|||||||
handNumber = 1,
|
handNumber = 1,
|
||||||
decisionToken = token,
|
decisionToken = token,
|
||||||
bettingReopened = true,
|
bettingReopened = true,
|
||||||
|
eligiblePot = 20,
|
||||||
|
currentBet = if (canCheck) 0 else 10,
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---------- fold is honoured, never silently substituted ----------
|
// ---------- fold is honoured, never silently substituted ----------
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ class SnapshotAndHumanAgentTest {
|
|||||||
handNumber = 1,
|
handNumber = 1,
|
||||||
decisionToken = token,
|
decisionToken = token,
|
||||||
bettingReopened = true,
|
bettingReopened = true,
|
||||||
|
eligiblePot = 15,
|
||||||
|
currentBet = 10,
|
||||||
)
|
)
|
||||||
|
|
||||||
// ---------- snapshots ----------
|
// ---------- snapshots ----------
|
||||||
@@ -216,6 +218,8 @@ class HumanOfferAndStreetStateTest {
|
|||||||
handNumber = 1,
|
handNumber = 1,
|
||||||
decisionToken = token,
|
decisionToken = token,
|
||||||
bettingReopened = true,
|
bettingReopened = true,
|
||||||
|
eligiblePot = 15,
|
||||||
|
currentBet = 10,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Codex note: the earlier test cancelled the coroutine, never cancel() itself. */
|
/** Codex note: the earlier test cancelled the coroutine, never cancel() itself. */
|
||||||
@@ -251,6 +255,11 @@ class HumanOfferAndStreetStateTest {
|
|||||||
seat.committedThisRound = 999
|
seat.committedThisRound = 999
|
||||||
assertEquals(500, offer.stack, "offer must not alias live seat state")
|
assertEquals(500, offer.stack, "offer must not alias live seat state")
|
||||||
assertEquals(listOf(0, 5), offer.hole)
|
assertEquals(listOf(0, 5), offer.hole)
|
||||||
|
assertEquals(1, offer.activeOpponents)
|
||||||
|
assertEquals(0, offer.seatsActingAfter)
|
||||||
|
assertEquals(10, offer.bigBlind)
|
||||||
|
assertEquals(15, offer.eligiblePot)
|
||||||
|
assertEquals(10, offer.currentBet)
|
||||||
|
|
||||||
human.submit(human.offer.value!!.token, Action(ActionType.FOLD))
|
human.submit(human.offer.value!!.token, Action(ActionType.FOLD))
|
||||||
decision.await()
|
decision.await()
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import com.jsjdesigns.poker.core.Card
|
|||||||
import com.jsjdesigns.poker.core.PreflopChart
|
import com.jsjdesigns.poker.core.PreflopChart
|
||||||
import com.jsjdesigns.poker.core.Suit
|
import com.jsjdesigns.poker.core.Suit
|
||||||
import com.jsjdesigns.poker.game.ActionType
|
import com.jsjdesigns.poker.game.ActionType
|
||||||
|
import com.jsjdesigns.poker.game.DecisionContext
|
||||||
|
import com.jsjdesigns.poker.game.PlayerAgent
|
||||||
import com.jsjdesigns.poker.game.Seat
|
import com.jsjdesigns.poker.game.Seat
|
||||||
import com.jsjdesigns.poker.game.Street
|
import com.jsjdesigns.poker.game.Street
|
||||||
import com.jsjdesigns.poker.game.Table
|
import com.jsjdesigns.poker.game.Table
|
||||||
@@ -21,6 +23,26 @@ private const val SMALL_BLIND = 1
|
|||||||
private const val BIG_BLIND = 2
|
private const val BIG_BLIND = 2
|
||||||
private const val STARTING_STACK = 200 // 100 big blinds
|
private const val STARTING_STACK = 200 // 100 big blinds
|
||||||
|
|
||||||
|
private enum class StackPolicy {
|
||||||
|
RESET_EACH_HAND,
|
||||||
|
PERSIST_WITH_REBUY,
|
||||||
|
}
|
||||||
|
|
||||||
|
internal data class ShortStackCoverage(
|
||||||
|
var decisions: Int = 0,
|
||||||
|
var eligiblePotDiffersFromPot: Int = 0,
|
||||||
|
var callClampedByStack: Int = 0,
|
||||||
|
) {
|
||||||
|
val passes: Boolean
|
||||||
|
get() = eligiblePotDiffersFromPot > 0 && callClampedByStack > 0
|
||||||
|
|
||||||
|
fun observe(ctx: DecisionContext) {
|
||||||
|
decisions++
|
||||||
|
if (ctx.eligiblePot != ctx.pot) eligiblePotDiffersFromPot++
|
||||||
|
if (ctx.toCall > ctx.stack) callClampedByStack++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
internal class Stats(val name: String, val profile: BotProfile) {
|
internal class Stats(val name: String, val profile: BotProfile) {
|
||||||
var net = 0L
|
var net = 0L
|
||||||
var hands = 0
|
var hands = 0
|
||||||
@@ -46,6 +68,8 @@ private fun runTable(
|
|||||||
hands: Int,
|
hands: Int,
|
||||||
seed: Long,
|
seed: Long,
|
||||||
verbose: Boolean = true,
|
verbose: Boolean = true,
|
||||||
|
stackPolicy: StackPolicy = StackPolicy.RESET_EACH_HAND,
|
||||||
|
shortStackCoverage: ShortStackCoverage? = null,
|
||||||
): List<Stats> = runBlocking {
|
): List<Stats> = runBlocking {
|
||||||
// The deck gets its own RNG. If bots drew from the same stream, the number of
|
// The deck gets its own RNG. If bots drew from the same stream, the number of
|
||||||
// Monte Carlo rollouts a bot performs — which varies by skill level — would
|
// Monte Carlo rollouts a bot performs — which varies by skill level — would
|
||||||
@@ -54,7 +78,13 @@ private fun runTable(
|
|||||||
val deckRandom = Random(seed)
|
val deckRandom = Random(seed)
|
||||||
val bots = roster.mapIndexed { i, p -> MathBot(p, Random(seed * 31 + i)) }
|
val bots = roster.mapIndexed { i, p -> MathBot(p, Random(seed * 31 + i)) }
|
||||||
val stats = roster.map { Stats(it.name, it) }
|
val stats = roster.map { Stats(it.name, it) }
|
||||||
val seats = roster.mapIndexed { i, p -> Seat(i, p.name, STARTING_STACK, bots[i]) }
|
val seats = roster.mapIndexed { i, p ->
|
||||||
|
val observedAgent = PlayerAgent { ctx ->
|
||||||
|
shortStackCoverage?.observe(ctx)
|
||||||
|
bots[i].act(ctx)
|
||||||
|
}
|
||||||
|
Seat(i, p.name, STARTING_STACK, observedAgent)
|
||||||
|
}
|
||||||
val table = Table(seats, SMALL_BLIND, BIG_BLIND, deckRandom)
|
val table = Table(seats, SMALL_BLIND, BIG_BLIND, deckRandom)
|
||||||
|
|
||||||
if (verbose) {
|
if (verbose) {
|
||||||
@@ -69,7 +99,12 @@ private fun runTable(
|
|||||||
val raisedPre = BooleanArray(seats.size)
|
val raisedPre = BooleanArray(seats.size)
|
||||||
|
|
||||||
repeat(hands) {
|
repeat(hands) {
|
||||||
for (s in seats) s.stack = STARTING_STACK
|
when (stackPolicy) {
|
||||||
|
StackPolicy.RESET_EACH_HAND ->
|
||||||
|
for (s in seats) s.stack = STARTING_STACK
|
||||||
|
StackPolicy.PERSIST_WITH_REBUY ->
|
||||||
|
for (s in seats) if (s.stack < BIG_BLIND) s.stack = STARTING_STACK
|
||||||
|
}
|
||||||
java.util.Arrays.fill(enteredPot, false)
|
java.util.Arrays.fill(enteredPot, false)
|
||||||
java.util.Arrays.fill(raisedPre, false)
|
java.util.Arrays.fill(raisedPre, false)
|
||||||
table.advanceButton()
|
table.advanceButton()
|
||||||
@@ -134,6 +169,44 @@ private fun runTable(
|
|||||||
stats
|
stats
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exercises the pricing paths that an equal-stack, reset-every-hand simulation
|
||||||
|
* cannot reach. Stacks persist exactly as they do for app-controlled opponents:
|
||||||
|
* a busted seat reloads only when it has less than one big blind.
|
||||||
|
*/
|
||||||
|
internal fun runShortStackCoverage(
|
||||||
|
hands: Int,
|
||||||
|
seed: Long,
|
||||||
|
verbose: Boolean = true,
|
||||||
|
): ShortStackCoverage {
|
||||||
|
require(hands > 0) { "short-stack coverage requires at least one hand" }
|
||||||
|
val coverage = ShortStackCoverage()
|
||||||
|
val roster = PlayStyle.ALL.map { style ->
|
||||||
|
// Beginner keeps this structural coverage pass inexpensive. Pricing is
|
||||||
|
// shared by every skill level and is asserted separately by engine tests.
|
||||||
|
BotProfile(style.label, SkillLevel.BEGINNER, style)
|
||||||
|
}
|
||||||
|
runTable(
|
||||||
|
label = "Persistent-stack coverage",
|
||||||
|
roster = roster,
|
||||||
|
hands = hands,
|
||||||
|
seed = seed,
|
||||||
|
verbose = false,
|
||||||
|
stackPolicy = StackPolicy.PERSIST_WITH_REBUY,
|
||||||
|
shortStackCoverage = coverage,
|
||||||
|
)
|
||||||
|
if (verbose) {
|
||||||
|
println(
|
||||||
|
"Persistent-stack coverage ($hands hands, seed=$seed): " +
|
||||||
|
"decisions=${coverage.decisions} " +
|
||||||
|
"eligiblePotDiffersFromPot=${coverage.eligiblePotDiffersFromPot} " +
|
||||||
|
"callClampedByStack=${coverage.callClampedByStack} " +
|
||||||
|
if (coverage.passes) "PASS" else "FAIL",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return coverage
|
||||||
|
}
|
||||||
|
|
||||||
internal data class LadderSeedResult(
|
internal data class LadderSeedResult(
|
||||||
val seed: Long,
|
val seed: Long,
|
||||||
val bbPer100: Map<SkillLevel, Double>,
|
val bbPer100: Map<SkillLevel, Double>,
|
||||||
@@ -378,7 +451,13 @@ private fun runCalibration(handsPerSeed: Int, seedCount: Int, baseSeed: Long) {
|
|||||||
println(" %-42s %s %s".format(contract.label, if (contract.passes) "PASS" else "FAIL", contract.detail))
|
println(" %-42s %s %s".format(contract.label, if (contract.passes) "PASS" else "FAIL", contract.detail))
|
||||||
}
|
}
|
||||||
|
|
||||||
check(report.passes && styleContracts.all { it.passes }) {
|
println()
|
||||||
|
val shortStackCoverage = runShortStackCoverage(
|
||||||
|
hands = 3_000,
|
||||||
|
seed = baseSeed + seedCount + 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
check(report.passes && styleContracts.all { it.passes } && shortStackCoverage.passes) {
|
||||||
"bot calibration failed; do not tune constants against a single seed or one aggregate number"
|
"bot calibration failed; do not tune constants against a single seed or one aggregate number"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -407,6 +486,14 @@ private fun printChart() {
|
|||||||
|
|
||||||
fun main(args: Array<String>) {
|
fun main(args: Array<String>) {
|
||||||
if (args.firstOrNull() == "chart") { printChart(); return }
|
if (args.firstOrNull() == "chart") { printChart(); return }
|
||||||
|
if (args.firstOrNull() == "short-stacks") {
|
||||||
|
val hands = args.getOrNull(1)?.toIntOrNull() ?: 3_000
|
||||||
|
val seed = args.getOrNull(2)?.toLongOrNull() ?: 20_260_729L
|
||||||
|
check(runShortStackCoverage(hands, seed).passes) {
|
||||||
|
"persistent-stack calibration did not exercise both short-stack pricing paths"
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
if (args.firstOrNull() == "styles") {
|
if (args.firstOrNull() == "styles") {
|
||||||
val hands = args.getOrNull(1)?.toIntOrNull() ?: 20_000
|
val hands = args.getOrNull(1)?.toIntOrNull() ?: 20_000
|
||||||
val seed = args.getOrNull(2)?.toLongOrNull() ?: 20_260_732L
|
val seed = args.getOrNull(2)?.toLongOrNull() ?: 20_260_732L
|
||||||
|
|||||||
@@ -99,4 +99,31 @@ class CalibrationPolicyTest {
|
|||||||
.passes
|
.passes
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `persistent-stack calibration exercises short-stack pricing`() {
|
||||||
|
val coverage = runShortStackCoverage(
|
||||||
|
hands = 1_000,
|
||||||
|
seed = 20_260_729L,
|
||||||
|
verbose = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(coverage.eligiblePotDiffersFromPot > 0)
|
||||||
|
assertTrue(coverage.callClampedByStack > 0)
|
||||||
|
assertTrue(coverage.passes)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `short-stack coverage requires both pricing paths`() {
|
||||||
|
assertFalse(ShortStackCoverage().passes)
|
||||||
|
assertFalse(ShortStackCoverage(10, eligiblePotDiffersFromPot = 1).passes)
|
||||||
|
assertFalse(ShortStackCoverage(10, callClampedByStack = 1).passes)
|
||||||
|
assertTrue(
|
||||||
|
ShortStackCoverage(
|
||||||
|
decisions = 10,
|
||||||
|
eligiblePotDiffersFromPot = 1,
|
||||||
|
callClampedByStack = 1,
|
||||||
|
).passes,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user