linked to yakus
This commit is contained in:
parent
6d9ff97834
commit
09dd8a70b2
3 changed files with 382 additions and 18 deletions
|
|
@ -36,6 +36,7 @@ const BUTTON_STYLES: Record<string, ButtonConfig> = {
|
|||
kan: { text: "Kan", color: "#FFCC33", action: 3 },
|
||||
ron: { text: "Ron", color: "#FF3060", action: 4 },
|
||||
tsumo: { text: "Tsumo", color: "#FF3060", action: 5 },
|
||||
riichi: { text: "Riichi", color: "#66ccff", action: 6 },
|
||||
back: { text: "Retour", color: "#FF9030", action: 0 }
|
||||
};
|
||||
|
||||
|
|
@ -50,9 +51,10 @@ export function clickAction(
|
|||
pon: boolean,
|
||||
kan: boolean,
|
||||
ron: boolean,
|
||||
tsumo: boolean
|
||||
tsumo: boolean,
|
||||
riichi: boolean
|
||||
): number {
|
||||
const activeButtons = getActiveButtons(chii, pon, kan, ron, tsumo);
|
||||
const activeButtons = getActiveButtons(chii, pon, kan, ron, tsumo, riichi);
|
||||
|
||||
if (activeButtons.length === 0) {
|
||||
return -1;
|
||||
|
|
@ -91,14 +93,16 @@ export function drawButtons(
|
|||
pon: boolean,
|
||||
kan: boolean,
|
||||
ron: boolean,
|
||||
tsumo: boolean
|
||||
tsumo: boolean,
|
||||
riichi: boolean
|
||||
): void {
|
||||
const buttonFunctions: [boolean, ButtonRenderer][] = [
|
||||
[chii, (ctx, x, y) => renderButton(ctx, x, y, BUTTON_STYLES.chii)],
|
||||
[pon, (ctx, x, y) => renderButton(ctx, x, y, BUTTON_STYLES.pon)],
|
||||
[kan, (ctx, x, y) => renderButton(ctx, x, y, BUTTON_STYLES.kan)],
|
||||
[ron, (ctx, x, y) => renderButton(ctx, x, y, BUTTON_STYLES.ron)],
|
||||
[tsumo, (ctx, x, y) => renderButton(ctx, x, y, BUTTON_STYLES.tsumo)]
|
||||
[tsumo, (ctx, x, y) => renderButton(ctx, x, y, BUTTON_STYLES.tsumo)],
|
||||
[riichi, (ctx, x, y) => renderButton(ctx, x, y, BUTTON_STYLES.riichi)]
|
||||
];
|
||||
|
||||
// Only show the pass button if at least one other button is active
|
||||
|
|
@ -227,11 +231,13 @@ function getActiveButtons(
|
|||
pon: boolean,
|
||||
kan: boolean,
|
||||
ron: boolean,
|
||||
tsumo: boolean
|
||||
tsumo: boolean,
|
||||
riichi: boolean
|
||||
): number[] {
|
||||
const buttonConfigs: [boolean, number][] = [
|
||||
[tsumo, BUTTON_STYLES.tsumo.action],
|
||||
[ron, BUTTON_STYLES.ron.action],
|
||||
[riichi, BUTTON_STYLES.riichi.action],
|
||||
[kan, BUTTON_STYLES.kan.action],
|
||||
[pon, BUTTON_STYLES.pon.action],
|
||||
[chii, BUTTON_STYLES.chii.action]
|
||||
|
|
|
|||
222
src/display/dp8.ts
Normal file
222
src/display/dp8.ts
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
import { Deck } from "../deck";
|
||||
import { Hand } from "../hand";
|
||||
import { Game } from "../game";
|
||||
|
||||
function showPlayButton() {
|
||||
const button = document.createElement('button');
|
||||
button.id = 'playButton';
|
||||
button.textContent = 'Jouer';
|
||||
button.style.position = 'absolute';
|
||||
button.style.left = `${1050/2}px`;
|
||||
button.style.top = `${1050/2}px`;
|
||||
button.style.transform = 'translate(-50%, -50%)';
|
||||
button.style.fontSize = '2rem';
|
||||
button.style.padding = '1em 2em';
|
||||
button.style.zIndex = '1000';
|
||||
|
||||
document.body.appendChild(button);
|
||||
|
||||
button.addEventListener('click', async () => {
|
||||
button.disabled = true;
|
||||
button.textContent = 'Chargement...';
|
||||
await initDisplay();
|
||||
button.remove();
|
||||
});
|
||||
}
|
||||
|
||||
class RiichiGameManagerDP8 {
|
||||
private readonly CANVAS_ID: string = "myCanvas";
|
||||
private readonly BG_RECT = { x: 0, y: 0, w: 1050, h: 1050 };
|
||||
private readonly FPS: number = 60;
|
||||
private readonly FRAME_INTERVAL: number = 1000 / this.FPS;
|
||||
|
||||
private static instance: RiichiGameManagerDP8;
|
||||
|
||||
private canvas: HTMLCanvasElement;
|
||||
private ctx: CanvasRenderingContext2D;
|
||||
private staticCanvas: HTMLCanvasElement;
|
||||
private staticCtx: CanvasRenderingContext2D;
|
||||
|
||||
private mouse = { x: 0, y: 0 };
|
||||
private game: Game | null = null;
|
||||
private decks: Array<Deck> = [];
|
||||
private hands: Array<Hand> = [];
|
||||
|
||||
private animationFrameId: number | null = null;
|
||||
private lastFrameTime: number = 0;
|
||||
private isInitialized: boolean = false;
|
||||
|
||||
private cleanupCallbacks: Array<() => void> = [];
|
||||
|
||||
private constructor() {
|
||||
const canvas = document.getElementById(this.CANVAS_ID) as HTMLCanvasElement;
|
||||
if (!canvas) {
|
||||
throw new Error(`Canvas with ID '${this.CANVAS_ID}' not found`);
|
||||
}
|
||||
this.canvas = canvas;
|
||||
this.canvas.width = this.BG_RECT.w;
|
||||
this.canvas.height = this.BG_RECT.h;
|
||||
|
||||
const ctx = canvas.getContext("2d", { alpha: false });
|
||||
if (!ctx) {
|
||||
throw new Error("Unable to get 2D context");
|
||||
}
|
||||
this.ctx = ctx;
|
||||
|
||||
this.staticCanvas = document.createElement('canvas');
|
||||
this.staticCanvas.width = canvas.width;
|
||||
this.staticCanvas.height = canvas.height;
|
||||
|
||||
const staticCtx = this.staticCanvas.getContext("2d", { alpha: false });
|
||||
if (!staticCtx) {
|
||||
throw new Error("Unable to get static context");
|
||||
}
|
||||
this.staticCtx = staticCtx;
|
||||
}
|
||||
|
||||
public static getInstance(): RiichiGameManagerDP8 {
|
||||
if (!RiichiGameManagerDP8.instance) {
|
||||
RiichiGameManagerDP8.instance = new RiichiGameManagerDP8();
|
||||
}
|
||||
return RiichiGameManagerDP8.instance;
|
||||
}
|
||||
|
||||
private drawFrame(): void {
|
||||
if (this.game) {
|
||||
this.game.draw(this.mouse);
|
||||
|
||||
// If game finished and yakus enabled, draw the detected yakus on the left
|
||||
if (this.game.isFinished()) {
|
||||
const yakus = this.game.getLastYakus();
|
||||
const total = this.game.getLastTotalHan();
|
||||
if (yakus && yakus.length > 0) {
|
||||
const ctx = this.staticCtx;
|
||||
ctx.save();
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.6)';
|
||||
ctx.fillRect(20, 60, 300, 24 + yakus.length * 24 + 30);
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = '18px garamond';
|
||||
ctx.fillText('Yakus detected:', 30, 86);
|
||||
for (let i = 0; i < yakus.length; i++) {
|
||||
const y = 110 + i * 24;
|
||||
ctx.fillText(`${yakus[i].name} (${yakus[i].han})`, 30, y);
|
||||
}
|
||||
ctx.fillText(`Total han: ${total}`, 30, 110 + yakus.length * 24 + 8);
|
||||
ctx.restore();
|
||||
|
||||
// Blit to visible canvas
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
this.ctx.drawImage(this.staticCanvas, 0, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private startAnimationLoop(): void {
|
||||
const animationLoop = (currentTime: number) => {
|
||||
this.animationFrameId = requestAnimationFrame(animationLoop);
|
||||
const deltaTime = currentTime - this.lastFrameTime;
|
||||
if (deltaTime < this.FRAME_INTERVAL) return;
|
||||
this.lastFrameTime = currentTime - (deltaTime % this.FRAME_INTERVAL);
|
||||
this.drawFrame();
|
||||
};
|
||||
this.animationFrameId = requestAnimationFrame(animationLoop);
|
||||
}
|
||||
|
||||
private initEventListeners(): void {
|
||||
const mouseMoveHandler = (e: MouseEvent) => {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
this.mouse.x = e.clientX - rect.left;
|
||||
this.mouse.y = e.clientY - rect.top;
|
||||
};
|
||||
|
||||
const mouseDownHandler = (e: MouseEvent) => {
|
||||
if (this.game) this.game.click(e as any);
|
||||
};
|
||||
|
||||
this.canvas.addEventListener('mousemove', mouseMoveHandler);
|
||||
this.canvas.addEventListener('mousedown', mouseDownHandler);
|
||||
|
||||
this.cleanupCallbacks.push(() => {
|
||||
this.canvas.removeEventListener('mousemove', mouseMoveHandler);
|
||||
this.canvas.removeEventListener('mousedown', mouseDownHandler);
|
||||
});
|
||||
}
|
||||
|
||||
private async preloadDeck(deck: Deck): Promise<void> {
|
||||
await deck.preload();
|
||||
}
|
||||
|
||||
private async preloadHand(hand: Hand): Promise<void> {
|
||||
await hand.preload();
|
||||
}
|
||||
|
||||
public async initialize(): Promise<void> {
|
||||
if (this.isInitialized) return;
|
||||
|
||||
try {
|
||||
// Create the game with yakus enabled (dp8)
|
||||
this.game = new Game(
|
||||
this.ctx,
|
||||
this.canvas,
|
||||
this.staticCtx,
|
||||
this.staticCanvas,
|
||||
false,
|
||||
8,
|
||||
Math.floor(Math.random() * 4),
|
||||
true // enableYakus
|
||||
);
|
||||
|
||||
await Promise.all([
|
||||
...this.decks.map(d => this.preloadDeck(d)),
|
||||
...this.hands.map(h => this.preloadHand(h)),
|
||||
this.game.preload()
|
||||
]);
|
||||
|
||||
this.initEventListeners();
|
||||
this.startAnimationLoop();
|
||||
this.isInitialized = true;
|
||||
(window as any).cleanup = this.cleanup.bind(this);
|
||||
} catch (e) {
|
||||
console.error('Initialization error dp8', e);
|
||||
}
|
||||
}
|
||||
|
||||
public cleanup(): void {
|
||||
if (this.animationFrameId !== null) {
|
||||
cancelAnimationFrame(this.animationFrameId);
|
||||
this.animationFrameId = null;
|
||||
}
|
||||
this.cleanupCallbacks.forEach(cb => cb());
|
||||
this.cleanupCallbacks = [];
|
||||
if (this.game) (this.game as any).cleanup?.();
|
||||
this.game = null;
|
||||
this.decks.forEach(d => d.cleanup());
|
||||
this.hands.forEach(h => h.cleanup());
|
||||
this.decks = [];
|
||||
this.hands = [];
|
||||
this.isInitialized = false;
|
||||
this.lastFrameTime = 0;
|
||||
this.mouse = { x: 0, y: 0 };
|
||||
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
this.staticCtx.clearRect(0, 0, this.staticCanvas.width, this.staticCanvas.height);
|
||||
}
|
||||
}
|
||||
|
||||
export function cleanup(): void {
|
||||
RiichiGameManagerDP8.getInstance().cleanup();
|
||||
}
|
||||
|
||||
export async function initDisplay(): Promise<void> {
|
||||
await RiichiGameManagerDP8.getInstance().initialize();
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
cleanup: () => void;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
showPlayButton();
|
||||
}
|
||||
162
src/game.ts
162
src/game.ts
|
|
@ -4,6 +4,7 @@ import { Tile } from "./tile";
|
|||
import { Group } from "./group";
|
||||
import { drawButtons, clickAction, drawChiis, clickChii } from "./button";
|
||||
import { drawState } from "./state";
|
||||
import { yakus } from "./yakus/yaku";
|
||||
|
||||
export type MousePos = { x: number, y: number };
|
||||
|
||||
|
|
@ -41,6 +42,7 @@ export class Game {
|
|||
private waitingTime = GAME_CONSTANTS.WAITING_TIME.DEFAULT;
|
||||
private selectedTile: number | undefined = undefined;
|
||||
private canCall: boolean = false;
|
||||
private declaredRiichi: boolean[] = [];
|
||||
private hasPicked: boolean = false;
|
||||
private hasPlayed: boolean = false;
|
||||
private lastPlayed: number = Date.now();
|
||||
|
|
@ -58,6 +60,8 @@ export class Game {
|
|||
private readonly BG_RECT = GAME_CONSTANTS.BACKGROUND;
|
||||
private readonly rotations = [0, -GAME_CONSTANTS.PI / 2, -GAME_CONSTANTS.PI, GAME_CONSTANTS.PI / 2];
|
||||
|
||||
// When enableYakus is true the game will evaluate yakus on any win and
|
||||
// store the detected yakus for display.
|
||||
constructor(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
cv: HTMLCanvasElement,
|
||||
|
|
@ -65,7 +69,8 @@ export class Game {
|
|||
staticCv: HTMLCanvasElement,
|
||||
red: boolean = false,
|
||||
level: number = 0,
|
||||
windPlayer: number = 0
|
||||
windPlayer: number = 0,
|
||||
private enableYakus: boolean = false
|
||||
) {
|
||||
this.ctx = ctx;
|
||||
this.cv = cv;
|
||||
|
|
@ -80,6 +85,56 @@ export class Game {
|
|||
this.initializeGame();
|
||||
}
|
||||
|
||||
// Last detected yakus for the most recent win (player index => list)
|
||||
private lastYakus: Array<{ name: string; han: number }> = [];
|
||||
private lastTotalHan: number = 0;
|
||||
|
||||
/**
|
||||
* Compute yakus for a given player and populate lastYakus/lastTotalHan.
|
||||
* This uses the exported `yakus` table where each entry returns han (0 if not present).
|
||||
*/
|
||||
private computeYakusForPlayer(player: number): void {
|
||||
this.lastYakus = [];
|
||||
this.lastTotalHan = 0;
|
||||
|
||||
try {
|
||||
const hand = this.hands[player];
|
||||
const groups = this.groups[player] || [];
|
||||
|
||||
for (const k of Object.keys(yakus)) {
|
||||
// Call each yaku detector with (hand, groups, wind)
|
||||
// Some detector functions expect the player's wind as an index
|
||||
const fn = (yakus as any)[k];
|
||||
try {
|
||||
const han = fn(hand, groups, this.windPlayer);
|
||||
if (han && han > 0) {
|
||||
this.lastYakus.push({ name: k, han });
|
||||
this.lastTotalHan += han;
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore errors in any individual detector to avoid breaking the flow
|
||||
console.warn(`Yaku ${k} threw:`, e);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Failed to compute yakus:", e);
|
||||
this.lastYakus = [];
|
||||
this.lastTotalHan = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private resolveWin(player: number, resultCode: number, fromPlayer?: number): void {
|
||||
if (this.enableYakus) {
|
||||
this.computeYakusForPlayer(player);
|
||||
} else {
|
||||
this.lastYakus = [];
|
||||
this.lastTotalHan = 0;
|
||||
}
|
||||
|
||||
this.end = true;
|
||||
this.result = resultCode;
|
||||
}
|
||||
|
||||
private initializeGame(): void {
|
||||
this.deck.shuffle();
|
||||
|
||||
|
|
@ -93,6 +148,7 @@ export class Game {
|
|||
this.hands.push(this.deck.getRandomHand());
|
||||
this.discards.push([]);
|
||||
this.groups.push([]);
|
||||
this.declaredRiichi.push(false);
|
||||
}
|
||||
|
||||
this.lastDiscard = undefined;
|
||||
|
|
@ -137,8 +193,7 @@ export class Game {
|
|||
const rect = this.cv.getBoundingClientRect();
|
||||
|
||||
if (this.hasWin(0)) {
|
||||
this.end = true;
|
||||
this.result = 1;
|
||||
this.resolveWin(0, 1);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -164,6 +219,7 @@ export class Game {
|
|||
}
|
||||
}
|
||||
|
||||
const canRiichi = this.canDeclareRiichi(0);
|
||||
const action = clickAction(
|
||||
mp.x - rect.x,
|
||||
mp.y - rect.y,
|
||||
|
|
@ -171,11 +227,16 @@ export class Game {
|
|||
this.canDoAPon(),
|
||||
false && this.level > 1,
|
||||
canRon,
|
||||
canTsumo
|
||||
canTsumo,
|
||||
canRiichi
|
||||
);
|
||||
|
||||
if (this.canCall && action !== -1) {
|
||||
this.handleCallAction(action);
|
||||
} else if (action === 6) { // Riichi action
|
||||
if (canRiichi) {
|
||||
this.declareRiichi(0);
|
||||
}
|
||||
} else if (this.turn === 0 && this.selectedTile !== undefined) {
|
||||
this.handlePlayerDiscard();
|
||||
}
|
||||
|
|
@ -289,8 +350,7 @@ export class Game {
|
|||
|
||||
private handleBotTurn(): void {
|
||||
if (this.hasWin(this.turn)) {
|
||||
this.end = true;
|
||||
this.result = 2;
|
||||
this.resolveWin(this.turn, 2);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -303,6 +363,7 @@ export class Game {
|
|||
this.hasPlayed = true;
|
||||
|
||||
if (this.deck.length() <= 0) {
|
||||
// Draw / no tiles left
|
||||
this.result = 0;
|
||||
this.end = true;
|
||||
return;
|
||||
|
|
@ -364,8 +425,7 @@ export class Game {
|
|||
this.turn = 0;
|
||||
this.pick(0);
|
||||
if (this.hasWin(0)) {
|
||||
this.end = true;
|
||||
this.result = 1;
|
||||
this.resolveWin(0, 1);
|
||||
}
|
||||
} else {
|
||||
this.turn++;
|
||||
|
|
@ -456,8 +516,7 @@ export class Game {
|
|||
this.groups[p].push(new Group([t, tt[0], tt[1]], discardPlayer, p));
|
||||
|
||||
if (this.hasWin(p)) {
|
||||
this.end = true;
|
||||
this.result = p === 0 ? 1 : 2;
|
||||
this.resolveWin(p, p === 0 ? 1 : 2);
|
||||
}
|
||||
|
||||
this.updateWaitingTime();
|
||||
|
|
@ -518,6 +577,73 @@ export class Game {
|
|||
return this.hands[player].count(t.getFamily(), t.getValue()) >= 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the given player's closed hand is in tenpai (waiting).
|
||||
* We simulate adding any possible tile and check if it yields a winning hand.
|
||||
*/
|
||||
private isTenpai(player: number): boolean {
|
||||
const h = this.hands[player];
|
||||
// quick guard: hand should not already be winning
|
||||
if (h.toGroup() !== undefined) return false;
|
||||
|
||||
// Try all possible tile types
|
||||
// suits 1..3 values 1..9
|
||||
for (let fam = 1; fam <= 3; fam++) {
|
||||
for (let val = 1; val <= 9; val++) {
|
||||
const sim = new Hand();
|
||||
for (const t of h.getTiles()) {
|
||||
sim.push(new Tile(t.getFamily(), t.getValue(), t.isRed()));
|
||||
}
|
||||
sim.push(new Tile(fam, val, false));
|
||||
sim.sort();
|
||||
if (sim.toGroup() !== undefined) return true;
|
||||
}
|
||||
}
|
||||
|
||||
// winds family 4 values 1..4
|
||||
for (let val = 1; val <= 4; val++) {
|
||||
const sim = new Hand();
|
||||
for (const t of h.getTiles()) {
|
||||
sim.push(new Tile(t.getFamily(), t.getValue(), t.isRed()));
|
||||
}
|
||||
sim.push(new Tile(4, val, false));
|
||||
sim.sort();
|
||||
if (sim.toGroup() !== undefined) return true;
|
||||
}
|
||||
|
||||
// dragons family 5 values 1..3
|
||||
for (let val = 1; val <= 3; val++) {
|
||||
const sim = new Hand();
|
||||
for (const t of h.getTiles()) {
|
||||
sim.push(new Tile(t.getFamily(), t.getValue(), t.isRed()));
|
||||
}
|
||||
sim.push(new Tile(5, val, false));
|
||||
sim.sort();
|
||||
if (sim.toGroup() !== undefined) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private canDeclareRiichi(player: number): boolean {
|
||||
// Only allow declaring Riichi for player 0 in the UI; general rule: it's the player's turn,
|
||||
// they have just drawn (hasPicked true), haven't yet discarded (hasPlayed false), hand closed,
|
||||
// in tenpai, and haven't already declared.
|
||||
if (player !== 0) return false;
|
||||
if (this.turn !== player) return false;
|
||||
if (!this.hasPicked || this.hasPlayed) return false;
|
||||
if (this.groups[player] && this.groups[player].length > 0) return false; // not closed
|
||||
if (this.declaredRiichi[player]) return false;
|
||||
return this.isTenpai(player);
|
||||
}
|
||||
|
||||
private declareRiichi(player: number): void {
|
||||
this.declaredRiichi[player] = true;
|
||||
// For now we only set the flag. In a full implementation we'd place the riichi stick,
|
||||
// lock the hand, and handle the bet. We redraw to update UI.
|
||||
this.drawGame();
|
||||
}
|
||||
|
||||
private pon(p: number, thief: number = 0): void {
|
||||
const t = this.discards[p].pop() as Tile;
|
||||
this.lastDiscard = undefined;
|
||||
|
|
@ -530,8 +656,7 @@ export class Game {
|
|||
this.groups[thief].push(new Group([t, t2, t3], p, thief));
|
||||
|
||||
if (this.hasWin(thief)) {
|
||||
this.end = true;
|
||||
this.result = thief === 0 ? 1 : 2;
|
||||
this.resolveWin(thief, thief === 0 ? 1 : 2);
|
||||
}
|
||||
|
||||
this.updateWaitingTime();
|
||||
|
|
@ -584,13 +709,15 @@ export class Game {
|
|||
}
|
||||
}
|
||||
|
||||
const canRiichiLocal = this.canDeclareRiichi(0);
|
||||
drawButtons(
|
||||
this.staticCtx,
|
||||
this.canDoAChii().length > 0,
|
||||
this.canDoAPon(),
|
||||
false && this.level > 1,
|
||||
canRonLocal,
|
||||
canTsumoLocal
|
||||
canTsumoLocal,
|
||||
canRiichiLocal
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -774,4 +901,13 @@ export class Game {
|
|||
await this.deck.preload();
|
||||
await Promise.all(this.hands.map(h => h.preload()));
|
||||
}
|
||||
|
||||
// Expose last yakus for UI/display when enabled
|
||||
public getLastYakus(): Array<{ name: string; han: number }> {
|
||||
return this.lastYakus;
|
||||
}
|
||||
|
||||
public getLastTotalHan(): number {
|
||||
return this.lastTotalHan;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue